Add machine workshop, typed logic, mechanics and sensor-driven ECUs
This commit is contained in:
@@ -8,6 +8,7 @@ add_library(fluidsim_core
|
||||
src/engine_config.cpp
|
||||
src/carburetor.cpp
|
||||
src/ecu.cpp
|
||||
src/fuel_injector.cpp
|
||||
src/engine.cpp
|
||||
src/crankcase.cpp
|
||||
src/two_stroke_cylinder.cpp
|
||||
@@ -21,6 +22,19 @@ add_library(fluidsim_core
|
||||
src/scenario_snapshot.cpp
|
||||
src/scenario_geometry.cpp
|
||||
src/physical_geometry.cpp
|
||||
src/machine.cpp
|
||||
src/machine_ports.cpp
|
||||
src/mechanics.cpp
|
||||
src/machine_mechanics.cpp
|
||||
src/machine_sensors.cpp
|
||||
src/machine_logic.cpp
|
||||
src/logic_runtime.cpp
|
||||
src/logic_serialization.cpp
|
||||
src/logic_assembly.cpp
|
||||
src/logic_example.cpp
|
||||
src/speed_density_example.cpp
|
||||
src/machine_simulation.cpp
|
||||
src/machine_import.cpp
|
||||
src/solver.cpp
|
||||
src/vehicle.cpp
|
||||
src/simulation_ring_buffer.cpp
|
||||
@@ -49,6 +63,8 @@ add_executable(fluidsim_parity_trace tools/parity_trace.cpp)
|
||||
target_link_libraries(fluidsim_parity_trace PRIVATE fluidsim_core)
|
||||
add_executable(fluidsim_migrate_geometry tools/migrate_geometry.cpp)
|
||||
target_link_libraries(fluidsim_migrate_geometry PRIVATE fluidsim_core)
|
||||
add_executable(fluidsim_machine_benchmark tools/machine_benchmark.cpp)
|
||||
target_link_libraries(fluidsim_machine_benchmark PRIVATE fluidsim_core)
|
||||
|
||||
if(FLUIDSIM_BUILD_DESKTOP)
|
||||
find_package(SFML 3.0.2 COMPONENTS Graphics Audio QUIET)
|
||||
@@ -72,15 +88,22 @@ if(FLUIDSIM_BUILD_DESKTOP)
|
||||
endif()
|
||||
find_package(OpenGL REQUIRED)
|
||||
add_library(fluidsim_desktop_rendering
|
||||
src/desktop_ui.cpp src/scenario_menu.cpp src/engine_schematic.cpp src/engine_renderer_3d.cpp src/dyno_renderer.cpp)
|
||||
target_link_libraries(fluidsim_desktop_rendering PUBLIC fluidsim_core SFML::Graphics OpenGL::GL)
|
||||
src/desktop_ui.cpp src/scenario_menu.cpp src/engine_schematic.cpp src/engine_renderer_3d.cpp src/dyno_renderer.cpp
|
||||
src/machine_builder.cpp src/builder_geometry.cpp src/machine_renderer_3d.cpp src/window_presentation.cpp src/logic_editor.cpp
|
||||
src/audio_output_stream.cpp src/sound_engine.cpp)
|
||||
target_link_libraries(fluidsim_desktop_rendering PUBLIC fluidsim_core SFML::Graphics SFML::Audio OpenGL::GL)
|
||||
if(WIN32)
|
||||
target_link_libraries(fluidsim_desktop_rendering PRIVATE user32)
|
||||
endif()
|
||||
if(MSVC)
|
||||
target_compile_options(fluidsim_desktop_rendering PRIVATE /W4 /permissive-)
|
||||
else()
|
||||
target_compile_options(fluidsim_desktop_rendering PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
add_executable(fluidsim_desktop src/desktop_main.cpp src/audio_output_stream.cpp src/sound_engine.cpp)
|
||||
add_executable(fluidsim_desktop src/desktop_main.cpp)
|
||||
target_link_libraries(fluidsim_desktop PRIVATE fluidsim_desktop_rendering SFML::Audio)
|
||||
add_executable(fluidsim_machine_render_benchmark tools/machine_render_benchmark.cpp)
|
||||
target_link_libraries(fluidsim_machine_render_benchmark PRIVATE fluidsim_desktop_rendering)
|
||||
if(MSVC)
|
||||
target_compile_options(fluidsim_desktop PRIVATE /W4 /permissive-)
|
||||
else()
|
||||
@@ -117,6 +140,34 @@ endif()
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(fluidsim_speed_density_tests tests/speed_density_tests.cpp)
|
||||
target_link_libraries(fluidsim_speed_density_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_speed_density_tests COMMAND fluidsim_speed_density_tests)
|
||||
add_executable(fluidsim_sensor_ecu_tests tests/sensor_ecu_tests.cpp)
|
||||
target_link_libraries(fluidsim_sensor_ecu_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_sensor_ecu_tests COMMAND fluidsim_sensor_ecu_tests)
|
||||
add_executable(fluidsim_logic_tests tests/logic_tests.cpp)
|
||||
target_link_libraries(fluidsim_logic_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_logic_tests COMMAND fluidsim_logic_tests)
|
||||
add_executable(fluidsim_machine_logic_tests tests/machine_logic_tests.cpp)
|
||||
target_link_libraries(fluidsim_machine_logic_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_machine_logic_tests COMMAND fluidsim_machine_logic_tests)
|
||||
add_executable(fluidsim_machine_tests tests/machine_tests.cpp)
|
||||
target_link_libraries(fluidsim_machine_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_machine_tests COMMAND fluidsim_machine_tests)
|
||||
add_executable(fluidsim_machine_sensor_tests tests/machine_sensor_tests.cpp)
|
||||
target_link_libraries(fluidsim_machine_sensor_tests PRIVATE fluidsim_core)
|
||||
target_compile_definitions(fluidsim_machine_sensor_tests PRIVATE FLUIDSIM_SENSOR_EXAMPLE_FILE="${CMAKE_CURRENT_SOURCE_DIR}/machines/sensor-bench.machine.toml")
|
||||
add_test(NAME fluidsim_machine_sensor_tests COMMAND fluidsim_machine_sensor_tests)
|
||||
add_executable(fluidsim_machine_port_tests tests/machine_port_tests.cpp)
|
||||
target_link_libraries(fluidsim_machine_port_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_machine_port_tests COMMAND fluidsim_machine_port_tests)
|
||||
add_executable(fluidsim_mechanics_tests tests/mechanics_tests.cpp)
|
||||
target_link_libraries(fluidsim_mechanics_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_mechanics_tests COMMAND fluidsim_mechanics_tests)
|
||||
add_executable(fluidsim_machine_mechanics_tests tests/machine_mechanics_tests.cpp)
|
||||
target_link_libraries(fluidsim_machine_mechanics_tests PRIVATE fluidsim_core)
|
||||
add_test(NAME fluidsim_machine_mechanics_tests COMMAND fluidsim_machine_mechanics_tests)
|
||||
add_executable(fluidsim_physical_geometry_tests tests/physical_geometry_tests.cpp)
|
||||
target_link_libraries(fluidsim_physical_geometry_tests PRIVATE fluidsim_core)
|
||||
target_include_directories(fluidsim_physical_geometry_tests SYSTEM PRIVATE third_party/tomlplusplus)
|
||||
@@ -138,6 +189,16 @@ if(BUILD_TESTING)
|
||||
add_test(NAME fluidsim_scenario_examples COMMAND fluidsim_scenario_examples)
|
||||
endif()
|
||||
if(BUILD_TESTING AND FLUIDSIM_BUILD_DESKTOP)
|
||||
add_executable(fluidsim_logic_editor_tests tests/logic_editor_tests.cpp)
|
||||
target_link_libraries(fluidsim_logic_editor_tests PRIVATE fluidsim_desktop_rendering)
|
||||
add_test(NAME fluidsim_logic_editor_tests COMMAND fluidsim_logic_editor_tests)
|
||||
add_executable(fluidsim_menu_tests tests/menu_tests.cpp)
|
||||
target_link_libraries(fluidsim_menu_tests PRIVATE fluidsim_desktop_rendering)
|
||||
add_test(NAME fluidsim_menu_tests COMMAND fluidsim_menu_tests)
|
||||
add_test(NAME fluidsim_window_tests COMMAND fluidsim_menu_tests --window-check)
|
||||
add_executable(fluidsim_machine_builder_tests tests/machine_builder_tests.cpp)
|
||||
target_link_libraries(fluidsim_machine_builder_tests PRIVATE fluidsim_desktop_rendering)
|
||||
add_test(NAME fluidsim_machine_builder_tests COMMAND fluidsim_machine_builder_tests)
|
||||
add_executable(fluidsim_desktop_ui_tests tests/desktop_ui_tests.cpp)
|
||||
target_link_libraries(fluidsim_desktop_ui_tests PRIVATE fluidsim_desktop_rendering)
|
||||
add_test(NAME fluidsim_desktop_ui_tests COMMAND fluidsim_desktop_ui_tests)
|
||||
|
||||
70
README.md
70
README.md
@@ -19,6 +19,54 @@ radiation, thermodynamics and convergence under time/space refinement.
|
||||
|
||||
## Build and run (Windows and Linux)
|
||||
|
||||
The desktop now includes a [3D machine builder](docs/MACHINE_BUILDER.md). Open
|
||||
**Machine workshop** in the scenario menu to place and configure pipes, tanks,
|
||||
valves, engine cylinders and piston pumps. Saved machines support **Edit** and
|
||||
**Play**, and **Tab** switches between building and testing.
|
||||
The picker includes 3D previews, carburetors and ECUs. **Import engine layout**
|
||||
opens editable copies of the 450 single, twin, flat six and V8. Shift selects groups;
|
||||
blue pins carry ECU control wires, and ideal junctions accept multiple pipes at
|
||||
one point. Engine cylinders require an explicit connected fuel source.
|
||||
**Ctrl+C / Ctrl+V** copies and pastes selected groups with their internal air and
|
||||
ECU connections. **Assemblies** saves selected groups for reuse across machines.
|
||||
Escape cancels tools or selection; use **Menu** to leave the workshop.
|
||||
**Meters (M)** adds pressure, temperature, flow, AFR/lambda and shaft instruments
|
||||
with live traces and CSV export. Test playback slows to 1% speed and supports
|
||||
paused 10 ms steps. Carb/ECU and other safe settings can be tuned live; geometry
|
||||
and compiled settings stay locked until returning to Build.
|
||||
|
||||
The **Mech** category adds explicit shafts, flywheels, drive motors and brakes.
|
||||
Cylinders/pumps connect to named shafts; independent engines can run at different
|
||||
speeds. Try **Shaft, flywheel and brake bench** from saved machines. Mechanical-only
|
||||
machines need no gas pipes. Older machine saves migrate in memory, and new saves
|
||||
use version 10 with stable ports, mechanical parameters, sensor attachments,
|
||||
a separate typed signal graph and explicit sensor-ECU/injector bindings.
|
||||
The **Sense** inspector adds pressure/MAP, differential pressure, gas temperature,
|
||||
total pipe mass flow, crank speed/angle and throttle-position pickups. Sensor
|
||||
channels sample independently of recorded instruments and show validity/time;
|
||||
deleting a host leaves an unresolved binding. Right-click **Add sensor here**
|
||||
places a tap at the clicked pipe location.
|
||||
The saved **Automatic air supply / logic bench** runs pressure feedback through
|
||||
hysteresis, a master switch and an AND gate to a compressor drive command. Choose
|
||||
**Logic** to edit its graph or **Split** to view signals beside the 3D machine.
|
||||
The node library supports search, typed click/drag wiring and live controls;
|
||||
Boolean wires are red, Number green, with ring inputs and filled outputs.
|
||||
|
||||
ECUs now offer **Crank ignition**, **Alpha-N EFI** and **Speed-density EFI** presets alongside the
|
||||
labeled legacy controller. Their grouped Logic connectors require physical crank
|
||||
speed/position, plus actual TPS for Alpha-N or absolute MAP/IAT for speed-density.
|
||||
Intake chambers accept explicit fuel
|
||||
injector attachments with calibrated capacity and finite/ideal rail supply.
|
||||
Try `machines/sensor-ignition-bench.machine.toml` and
|
||||
`machines/sensor-alpha-n-bench.machine.toml` and
|
||||
`machines/sensor-speed-density-bench.machine.toml`; all have working fuel transport
|
||||
and combustion. See the [workshop guide](docs/MACHINE_BUILDER.md) for calibration
|
||||
and the current batch-delivery model.
|
||||
|
||||
Launch directly with `fluidsim_desktop --builder`, edit a saved design with
|
||||
`--machine machines/vacuum-pump.machine.toml`, or run it with
|
||||
`--play-machine machines/vacuum-pump.machine.toml`.
|
||||
|
||||
Both platforms use CMake 3.28 or newer, Git, and a C++20 compiler. The presets
|
||||
build with optimizations and debugging symbols (`RelWithDebInfo`). The first
|
||||
desktop configure fetches SFML 3.0.2 if it is not installed and needs network
|
||||
@@ -166,12 +214,16 @@ in each engine TOML's `[simulation] cells_per_meter`. The desktop engine editor
|
||||
has been removed; edit engine calibration in TOML.
|
||||
|
||||
|
||||
Launching `fluidsim_desktop` without `--scenario` opens a small scenario menu.
|
||||
Choose the twin, YZ125, Saab B205, or American V8 card, or select with Up/Down
|
||||
and press Enter.
|
||||
Launching `fluidsim_desktop` opens a maximized home screen with **Machine
|
||||
Workshop**, **Engine Scenarios**, and **Quit**. Workshop offers a new design or
|
||||
saved machines with separate Edit/Play actions. Engine Scenarios opens the engine
|
||||
collection with category filters, search, a preview and a Start button. Click a
|
||||
row to select it, then start with the button or Enter; Up/Down also selects rows.
|
||||
Escape returns from the scenario to the menu, stopping its simulation and
|
||||
audio so another scenario can be selected. Escape closes the menu at the category
|
||||
screen. Simulation and audio start after selection. Passing
|
||||
audio so another scenario can be selected. Escape returns from a browser to the
|
||||
home screen, and closes the app from Home. Menu, workshop and scenario entry all
|
||||
maximize the decorated Windows window, retaining its normal restore controls.
|
||||
Simulation and audio start when an engine is launched. Passing
|
||||
`--scenario twin`, `--scenario two-stroke`, `--scenario b205`, or `--scenario v8`
|
||||
bypasses the menu; `--scene` and `--exhaust` options also apply
|
||||
when choosing through the menu.
|
||||
@@ -556,10 +608,10 @@ for units, directory discovery, sections and pipe topology ordering.
|
||||
|
||||
### Engine families and inspired builds
|
||||
|
||||
The browser opens at engine categories; click a category to see variants, then
|
||||
click a variant or press Enter to start. Backspace, Left, Escape or the
|
||||
`< Categories` header returns to categories. Both levels support wheel and
|
||||
keyboard scrolling.
|
||||
Choose **Engine Scenarios** on the home screen. Categories are listed beside the
|
||||
engine collection; wheel over them to reach more families. Select a variant,
|
||||
then press Enter or **Start engine**. Ctrl+F focuses search. Backspace, Escape or
|
||||
**Back** returns to the main menu. Lists support wheel and keyboard scrolling.
|
||||
|
||||
`EngineConfig` supplies cylinder count (up to 12), inline/V/boxer layout,
|
||||
bank angle, bore/stroke/rod, compression, firing phases, rev limit and fueling.
|
||||
|
||||
354
docs/COMPONENT_IMPLEMENTATION.md
Normal file
354
docs/COMPONENT_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Component implementation progress
|
||||
|
||||
Active goal started 2026-11-01: implement the full
|
||||
[machine systems roadmap](MECHANICAL_BUILDER_PLAN.md), in its integrated order.
|
||||
The scope includes the later components; they have not been dropped because they
|
||||
need additional physical models. Preserve existing working-tree changes and
|
||||
user-edited machine files.
|
||||
|
||||
## First implementation: typed port foundation
|
||||
|
||||
Implemented:
|
||||
|
||||
- Shared descriptors for Gas, Rotational, Translational, Boolean, Number and
|
||||
legacy ECU assignment. Direction, cardinality, quantity, pressure reference,
|
||||
required/default values and numeric ranges are explicit.
|
||||
- Typed Boolean/Number values with validity and simulation timestamps. Finite
|
||||
numeric checks and explicit type matching prevent implicit truth conversions.
|
||||
- A shared compatibility function used by connection previews, edits and
|
||||
document validation. Runtime compilation validates the same document.
|
||||
- Explicit gas-domain filtering in pipe geometry updates, fluid compilation and
|
||||
fuel-source searches, avoiding assumptions that every non-ECU link carries gas.
|
||||
- Version 5 stable port/method names, custom physical-port identities and loading
|
||||
of versions 1 through 4. Zero-storage junctions expose one shared port; old
|
||||
numeric aliases migrate to it.
|
||||
- Shared UI port colors and silhouettes: red Boolean, green Number, input rings,
|
||||
filled outputs, neutral bidirectional gas connectors; legacy assignments blue.
|
||||
|
||||
The foundational types are real APIs. Boolean/Number nodes remain subsequent work;
|
||||
the first mechanical parts are implemented below.
|
||||
|
||||
## Mechanical parts milestone
|
||||
|
||||
Implemented Shaft / crankshaft, Flywheel, Drive motor and Brake / load with typed
|
||||
gold attachment ports, real runtime behavior, 3D meshes, Mech picker category,
|
||||
inspectors, live motor/load/friction tuning and named rotational instruments.
|
||||
Shaft inertia and initial states require Build. Effective flywheel inertia appears
|
||||
in the shaft inspector. Cylinders and pumps use their attached shaft; each ECU's
|
||||
cylinders must share one shaft. Moving a shaft leaves gas geometry unchanged.
|
||||
|
||||
The independent-shaft solver tracks kinetic energy, source/drive work and losses.
|
||||
Its drag integration splits stops and reversals without allowing brakes to create
|
||||
motion. Ideal speed drives report their supplied/absorbed work, including startup.
|
||||
Engine-connected shafts currently enforce forward-only timing. The legacy starter
|
||||
remains explicit shaft calibration until the dedicated starter milestone; new
|
||||
shafts default to zero starter torque.
|
||||
|
||||
Version 6 saves mechanical parameters/connections. Older documents migrate to
|
||||
visible shafts/drives/loads and keep legacy gauge definitions. Examples/imports,
|
||||
copy/paste, assembly references, live tuning and benchmark tools use explicit
|
||||
mechanics. Existing user files are not rewritten on load. The new
|
||||
`machines/shaft-drive-bench.machine.toml` is a ready-to-run mechanical example.
|
||||
|
||||
Validation: nine focused suites pass, including analytic acceleration and damping,
|
||||
work/energy accounting, brakes/static hold/reversal, two independent engines at
|
||||
1200/2400 RPM, live tuning one to 3600 RPM, required attachments, mixed-shaft ECU
|
||||
rejection, migration, save/load, builder/menu interactions and prior physics cases.
|
||||
Rendered review covers every mechanical inspector and a running bench at
|
||||
1920x1080 and 1280x720. This is not a global gas-plus-mechanics conservation study;
|
||||
the existing explicit component-coupling timestep limitations remain relevant.
|
||||
|
||||
## Sensor attachment milestone
|
||||
|
||||
Implemented six ideal sensor assemblies: absolute pressure / MAP, differential
|
||||
pressure, gas temperature / IAT, signed total pipe mass flow, crank speed/position
|
||||
and throttle position. They attach to existing physical hosts without consuming
|
||||
gas ports, adding storage or changing the fluid mesh. Pipe taps use normalized
|
||||
arclength. Total mass flow is explicitly distinct from the future fresh-air MAF
|
||||
sensor; gas temperature is not a head/coolant temperature model.
|
||||
|
||||
Sensor outputs use the typed port descriptors, sample timestamps and validity.
|
||||
Sampling occurs initially and after each audio sample's physics, providing the
|
||||
next signal-graph boundary's readings. Crank speed, wrapped angle and Boolean
|
||||
ideal synchronized status share a sample; shaft identity and unwrapped angle are
|
||||
retained for future timing consumers. A stopped shaft supplies valid zero speed.
|
||||
Sensor channels are independent of the eight recorded instrument channels.
|
||||
|
||||
Version 7 saves stable sensor identities, named kinds, host labels, normalized
|
||||
locations and pickup offsets, and preserves deleted-ID counters. Versions 1..6
|
||||
still load. Host deletion leaves a visible unresolved entity; copy/paste and
|
||||
assemblies remap internal hosts and retain unresolved external bindings.
|
||||
|
||||
The **Sense** inspector creates supported sensors, binds hosts/reference taps,
|
||||
adjusts tap positions and pickup offsets, focuses/removes attachments and shows
|
||||
live typed outputs. Right-click **Add sensor here** uses the clicked pipe location.
|
||||
3D markers select their sensor; unresolved-binding checks navigate to it.
|
||||
Attachments are Build-only and remain inspectable while running/paused.
|
||||
The new `machines/sensor-bench.machine.toml` demonstrates all six assemblies.
|
||||
|
||||
Validation covers sampling/units/signs, coherent timing and reverse crossings,
|
||||
stopped pickups, remeshing/transforms, no fluid/audio perturbation, more than eight
|
||||
sensor channels, host deletion, incompatible bindings, copy/paste and save/load,
|
||||
plus UI placement, undo/redo, diagnostics and Test edit locks. Rendered review
|
||||
covers pressure, crank and differential-pressure screens at 1920x1080/1280x720.
|
||||
All ten focused regression suites pass, as does migration/round-trip validation
|
||||
of the saved machines found in the workspace. The desktop executable is rebuilt.
|
||||
|
||||
## Logic runtime milestone
|
||||
|
||||
The document now owns a separate typed signal graph with stable node/pin keys,
|
||||
saved canvas coordinates, shared port compatibility and atomic connection edits.
|
||||
Version 8 saves that graph; versions 1..7 still load. Physical gas/mechanical
|
||||
links and legacy blue ECU assignments retain their separate meanings.
|
||||
|
||||
Implemented 35 runtime node kinds: Boolean constants, switches/buttons, numeric
|
||||
constants/sliders/inputs, six Boolean gates with configurable fan-in, comparisons,
|
||||
windows/hysteresis, arithmetic, clamp/gain/map, number selection, Bool-to-number,
|
||||
sensor references, command references, lamps/readouts, and Boolean/Number delays.
|
||||
Polymorphic numeric nodes currently use explicit configured quantities/references.
|
||||
Multiply/divide use a scalar second operand; pressure subtraction produces a
|
||||
differential, while absolute-pressure addition requires a differential offset.
|
||||
|
||||
Graph evaluation occurs before each physics sample, consuming the previous
|
||||
boundary's sensor sample and latching commands across gas CFL substeps. Evaluation
|
||||
uses a compiled order, rejects combinational cycles with a named cycle path and
|
||||
commits explicit memory next states together. Required unwired inputs block Test;
|
||||
commands have declared inactive defaults. Invalid math/sensors stay invalid with
|
||||
runtime diagnostics, and actuator consumers apply inactive fallbacks. Short button
|
||||
press/release sequences survive between boundaries; fresh Test resets transient
|
||||
controls and memory. Constants/thresholds can be tuned without restarting physics.
|
||||
|
||||
Commands own individual motor enable/speed/torque, brake enable, throttle opening
|
||||
or legacy shaft starter fields. Duplicate ownership and ECU/opening conflicts are
|
||||
rejected; dormant signal-owned fields start inactive. A shaft allows only one
|
||||
potentially enabled ideal speed drive. The physical inspector identifies signal
|
||||
ownership and the live drive HUD uses the effective enabled state.
|
||||
|
||||
Connected harnesses follow physical assembly copy/paste. Internal parts/sensors/
|
||||
nodes remap independently; omitted physical bindings remain explicitly unresolved.
|
||||
The new `machines/logic-compressor-bench.machine.toml` runs actual pressure ->
|
||||
below-threshold hysteresis -> master-enable AND -> compressor motor enable, with
|
||||
a speed slider, targeted readouts and recorded pressure/RPM/drive-power channels.
|
||||
|
||||
Core acceptance covers gate truth tables, unit/reference rules, fan-out/cardinality,
|
||||
unknown keys/types, ordering/cycles, delayed feedback, math faults, manual event
|
||||
ordering/reset, live tuning, command ownership/fallbacks, automatic compressor
|
||||
shutdown, brake/starter commands, sample-vs-CFL timing, saved harnesses and
|
||||
independent copied compressors. Twelve focused regression suites pass, along
|
||||
with workspace save migration checks. Rendered review covers signal-owned drive
|
||||
controls and the running pressure bench at 1920x1080 and 1280x720. The desktop is
|
||||
rebuilt at this runtime milestone. Timers, edge nodes and latches remain later work.
|
||||
|
||||
## Logic editor milestone
|
||||
|
||||
Implemented **3D / Logic / Split** workspace tabs with one document, selection
|
||||
and Test runtime. Canvas pan/zoom and the 3D camera remain independent. Selecting
|
||||
a sensor/command reference selects its physical host; **Focus in 3D** opens the
|
||||
split workspace. **Sense -> Show Logic** and physical **Command** buttons create
|
||||
or reveal references to existing entities without duplicating physical parts.
|
||||
|
||||
The node library has search, categories, an Objects list and compatible-node
|
||||
filtering while a pin is pending. New compatible numeric nodes adopt that pin's
|
||||
quantity/reference. Nodes drag by their headers, Shift/box selection supports
|
||||
groups, Home/F fit the graph/selection, and wheel zoom preserves the point under
|
||||
the pointer. Saved node positions are independent of 3D geometry.
|
||||
|
||||
Connections support click/click, dragging, fan-out, input rewiring, right-click
|
||||
pin disconnection and undo/redo. Rejected edits keep the old wiring and redo
|
||||
history. Boolean pins/wires are red, Number green; inputs are rings and outputs
|
||||
filled circles. Live values include units, required/default metadata, ranges,
|
||||
pressure references and validity. Candidate combinational cycles highlight the
|
||||
actual cycle nodes and retain the compiler's named path diagnostic.
|
||||
|
||||
Inspectors edit names, quantities/references, sensor/command bindings and node
|
||||
parameters. Invalid numeric text remains editable. Topology, control ranges,
|
||||
initial states and physical bindings require Build; safe constants/thresholds
|
||||
and canvas layout can change during Test. Switches, momentary buttons, sliders
|
||||
and numeric controls operate at simulation boundaries. Paused controls show queued
|
||||
values separately from frozen pins; slider motion coalesces. Focus loss, leaving
|
||||
Logic and stopping Test release momentary buttons. Numeric readouts have compact
|
||||
UI-sampled trends; the recorded meter channels remain separate.
|
||||
|
||||
Graph copy/paste preserves internal wires. Same-document sensor references share
|
||||
their existing sensor; cross-document references detach while retaining labels.
|
||||
Pasted command nodes require a new explicit target, preventing competing owners.
|
||||
Physical assembly copy/paste continues to remap complete internal harnesses.
|
||||
|
||||
Validation: thirteen focused regression suites pass. Interaction cases cover
|
||||
placement/search, typed wiring/rewiring, failed-edit rollback/redo, one-entry drag
|
||||
undo, zoom anchoring, live tuning, paused controls, focus/view-close button release,
|
||||
shared physical selection, split-view bounds and uninterrupted simulation time.
|
||||
Rendered review covers 1920x1080/1280x720, including live graphs, split view and the
|
||||
threshold inspector. Pixel checks confirm red/green filled outputs and hollow
|
||||
input centers. Updated rendering libraries are linked into
|
||||
`out/build/windows-gcc/fluidsim_desktop_preview.exe`; the canonical executable is
|
||||
currently locked by the user's running workshop, which was left intact.
|
||||
|
||||
Further graph/UI work remains on the roadmap: pinned operator panels, richer
|
||||
binding pickers/property help, visual wire waypoints and graph-wide automatic
|
||||
type inference, in addition to DPI scaling and visibility groups. Basic stage 2
|
||||
is usable; those refinements and the later component stages are not dropped.
|
||||
|
||||
## Sensor ECU and fuel-delivery milestone
|
||||
|
||||
Implemented the runtime primitives for the next component stage:
|
||||
|
||||
- Physical crank outputs now carry pickup/shaft identity, original acquisition
|
||||
time, unwrapped angle and measured speed through the signal graph. Unchanged
|
||||
routing retains that evidence; arithmetic creates ordinary numeric values.
|
||||
Delays retain their original acquisition time, so retimestamping a delayed
|
||||
value cannot make it a current timing measurement.
|
||||
- A crank-only wasted-spark clock validates matching current speed/angle channels,
|
||||
counts unwrapped crossings, accepts stopped pickups and rejects stale/mixed or
|
||||
reverse timing. Acquisition/recovery does not replay missed sparks. Changing
|
||||
advance cannot fire twice for one revolution.
|
||||
- Separate sensor ECU core with ignition-only and Alpha-N strategies. Ignition
|
||||
uses measured RPM and an advance map; Alpha-N requires measured TPS and uses a
|
||||
calibrated two-dimensional RPM/TPS table in kg per cylinder per four-stroke
|
||||
cycle. It never receives trapped cylinder air mass or global pedal demand.
|
||||
Required sensor faults cut outputs; the RPM limiter has hysteresis.
|
||||
- Explicit injector delivery core with capacity limiting, declared ideal or
|
||||
finite regulated rail supply, depletion and fail-closed command handling.
|
||||
Delivery adds conserved fuel species, fuel-origin tracer and sensible energy
|
||||
to an existing gas volume. This is continuous/batch-average delivery, not
|
||||
sequential pulse-width control or a liquid-pressure circuit.
|
||||
- Four-stroke cylinders accept consumed external spark pulses and cut commands.
|
||||
This path requires transported fuel and does not fall back to hidden internal
|
||||
spark timing. Burn progression follows the received event.
|
||||
|
||||
The cores are now integrated into saved machines and the workshop. The ECU
|
||||
inspector selects labeled Legacy ideal, Crank ignition or Alpha-N EFI presets.
|
||||
**Show ECU connector in Logic** creates/reveals one grouped connector. Crank
|
||||
speed/position are required green Number inputs; Alpha-N additionally requires
|
||||
actual TPS. The connector selects its physical ECU and copied graph-only
|
||||
connectors detach ownership. Physical assembly copies remap the complete harness.
|
||||
|
||||
Blue assignments declare controlled cylinders. Sensor-driven ECUs do not own
|
||||
throttle opening or read the global pedal; manual or separately commanded
|
||||
throttles remain available. Each cylinder has an explicit **Pickup TDC deg**
|
||||
calibration in the crank sensor's coordinate system. Its physical 720-degree
|
||||
phase is not used as hidden ECU synchronization. Commands and spark events use
|
||||
the existing simulation-sample boundaries, not UI updates.
|
||||
|
||||
An explicit injector attachment can be added to a chamber, bound to an Alpha-N
|
||||
ECU and configured with capacity, cylinder-dose count, fuel mass/temperature and
|
||||
finite or ideal regulated supply. A nozzle appears on its host. Total injector
|
||||
cylinder doses must match assigned cylinders; fuel is added once per physics
|
||||
sample and then transported by the gas solver. Live inspectors show delivered
|
||||
flow, remaining fuel, depletion and ECU fault/limiter status. Ignition maps and
|
||||
RPM limits can be tuned live; strategy, fuel tables, attachments and pickup
|
||||
calibration require Build.
|
||||
|
||||
Version 9 saves ECU strategy/fuel maps, grouped input schemas, pickup calibration
|
||||
and injector bindings. Versions 1..8 retain their legacy controller behavior.
|
||||
Deleted/omitted ECU references stay unresolved with labels; IDs remap on assembly
|
||||
insertion. Existing user files are not rewritten. Ready-to-run examples are
|
||||
`machines/sensor-ignition-bench.machine.toml` and
|
||||
`machines/sensor-alpha-n-bench.machine.toml`, both driven at 3000 RPM.
|
||||
|
||||
The delivered EFI model is open-loop, continuous/batch-average intake delivery.
|
||||
It does not claim sequential pulse timing, modeled liquid rail pressure or
|
||||
evaporation/wall-film dynamics. Those abstractions remain explicitly labeled;
|
||||
advanced sensor strategies remain in later stages.
|
||||
|
||||
The new sensor-ECU acceptance target covers physical sensor -> graph timing,
|
||||
stale/mixed/arithmetic rejection, delayed acquisition evidence, spark crossings,
|
||||
RPM/TPS interpolation, fault/recovery and limiter behavior, ECU demand -> gas-host
|
||||
fuel delivery, finite-supply mass/energy accounting and external spark actuation.
|
||||
Acceptance also covers actual combustion in both sensor-driven examples,
|
||||
live loss/recovery of timing, foreign-shaft and constant-number rejection,
|
||||
depletion in the machine runtime, live ignition tuning without restarting time,
|
||||
save/load, partial/full assemblies and UI preset/connector/injector controls.
|
||||
All fourteen focused suites pass after rebuilding affected targets. Workspace
|
||||
machine migration/round trips pass from the repository root. Rendered review
|
||||
covers the new graph, ECU and injector screens at 1920x1080 and 1280x720.
|
||||
Wasted exhaust sparks retain the last trapped-charge AFR reading; valid timing
|
||||
still samples fresh charge when ignition is cut by the limiter.
|
||||
|
||||
The Alpha-N example was opened in Build mode with
|
||||
`out/build/windows-gcc/fluidsim_desktop_preview.exe`, leaving the original
|
||||
workshop intact. The final checked binary, including the last charge-instrument
|
||||
fix, is `out/build/windows-gcc/fluidsim_desktop_ecu_preview.exe`. Open sessions
|
||||
were not restarted to replace a locked executable; recheck live processes before
|
||||
linking or launching the next review build.
|
||||
|
||||
## Speed-density ECU milestone (stage 4, first slice)
|
||||
|
||||
Added a third sensor-driven preset, **Speed-density EFI**. Its grouped connector
|
||||
requires four Number inputs from three physical sensor assemblies: crank speed,
|
||||
crank position, manifold **absolute** pressure and intake gas temperature. TPS is
|
||||
not required in this basic mode. Gauge/differential pressure cannot connect to
|
||||
MAP; invalid, zero or stale MAP/IAT values cut fuel and new ignition events.
|
||||
|
||||
Fuel demand uses a declared dry ideal-gas estimate:
|
||||
`air_kg_per_cylinder_cycle = MAP * displacement * VE / (R_air * IAT)`, followed
|
||||
by target AFR and the four-stroke RPM/120 conversion. Displacement, air gas
|
||||
constant, target AFR and the RPM/MAP volumetric-efficiency table are calibration,
|
||||
not hidden cylinder measurements. VE interpolates both axes and clamps to table
|
||||
edges. The gas network still transports the explicitly injected fuel. This
|
||||
open-loop estimate does not measure fresh-air fraction in diluted intake gas or
|
||||
provide warm-up, acceleration enrichment, lambda trim or sequential timing.
|
||||
|
||||
The preset, required input schema, calibration fields and EFI injector target
|
||||
picker are integrated into the workshop. **SD** calibration fields display cc,
|
||||
kPa and VE percent and require Build. Ignition-map/RPM-limit tuning remains live.
|
||||
Shared strategy names/keys validate the document, graph, runtime and serializer;
|
||||
unknown strategies fail explicitly. Format 10 saves the new VE/displacement
|
||||
calibration and connector schema; versions 1..9 still load.
|
||||
|
||||
`machines/sensor-speed-density-bench.machine.toml` is a working 3000 RPM example
|
||||
with physical intake MAP/IAT taps, an explicit injector and actual combustion.
|
||||
Its calibration is set when constructing the example; runtime fuel estimation
|
||||
does not query cylinder air mass or geometry.
|
||||
|
||||
Validation: fifteen focused suites pass, including ideal-gas charge cases,
|
||||
pressure/temperature/displacement scaling, two-axis VE interpolation, required
|
||||
sensor faults, live MAP loss/recovery with combustion, missing IAT host, input
|
||||
reference types, save/load, assembly remapping and UI preset/connector controls.
|
||||
Workspace machine migration/round trips pass from the repository root. Rendered
|
||||
review at 1920x1080 and 1280x720 covers the four-input graph and ECU/injector
|
||||
inspectors. Pneumatic valves and the pressure-switch supply circuit remain the
|
||||
next stage-4 delivery; they are not implemented by this ECU milestone.
|
||||
|
||||
Checked desktop build: `out/build/windows-gcc/fluidsim_desktop_speed_density_preview.exe`.
|
||||
Existing workshop windows were left intact. Next session should start with the
|
||||
physical valve slice below; no valve implementation is currently in progress.
|
||||
|
||||
### Next physical component slice
|
||||
|
||||
Implement needle, solenoid shutoff, differential relief and non-relieving
|
||||
pressure-regulator behavior through the existing conservative gas boundaries.
|
||||
Use explicit inlet/outlet paths and real body/downstream pressure; do not enforce
|
||||
a setpoint by assigning gas pressure. Needle opening and solenoid coils need
|
||||
typed signal command ownership; the regulator's pressure demand must declare an
|
||||
absolute/gauge reference. Relief needs cracking/reseat hysteresis, solenoids need
|
||||
normally-open/closed rest states and finite travel, and regulator response must
|
||||
remain bounded when supply falls below demand. Include a pressure-switch preset
|
||||
and a finite-tank circuit with flow, depletion and conservation acceptance cases.
|
||||
Integrate picker modes, inspectors, save/load and assemblies before further UI
|
||||
polishing. Keep this and all later roadmap stages in the active goal.
|
||||
|
||||
## Delivery backlog
|
||||
|
||||
| Stage | Scope | State |
|
||||
| --- | --- | --- |
|
||||
| 0 | Typed ports, validation, stable identities and migration | Complete |
|
||||
| 1 | Explicit shafts, flywheel, drive motor, brake/load, sensor attachments and shaft instruments | Complete; six basic sensor assemblies implemented |
|
||||
| 2 | Logic canvas; buttons, switches, constants/sliders; gates, thresholds, arithmetic, mapping, sensors, commands and displays | Basic runtime and editor integrated/verified; advanced graph UX and operator panels remain |
|
||||
| 3 | Crank sensing, ignition and Alpha-N ECU presets, explicit fuel delivery and ECU connector UI | Basic presets integrated/verified, including physical combustion, saved bindings and workshop controls |
|
||||
| 4 | MAP/IAT sensing, speed-density ECU, needle/shutoff/regulator/relief valves and pressure-switch control | Speed-density preset integrated/verified; pneumatic valves and pressure-switch circuit remain |
|
||||
| 5 | Gears, clutch, starter; directional valves, pneumatic cylinders, linear loads/springs/dampers and quick exhaust; latches/timers/memory | Planned |
|
||||
| 6 | MAF/sequential ECU options; air motors, rotary compressors, filters, nozzles and resonator/silencer assemblies | Planned |
|
||||
| Later | Belt/pulley drives, heated/cooled chambers, ejectors, turbines/turbochargers and general linkages | Planned |
|
||||
|
||||
Each stage needs actual runtime behavior, save/load and assembly support, usable
|
||||
UI controls and focused acceptance checks. Keep this file current at milestones.
|
||||
|
||||
Stage 0 validation: six focused suites pass (machine ports, machine physics and
|
||||
serialization, builder interactions, menu navigation, native maximization and
|
||||
desktop UI). Tests cover all current part descriptors, signal types/references,
|
||||
invalid values/defaults, input direction/cardinality, custom-port reordering,
|
||||
legacy ECU fan-out, old junction aliases and all saved machines found locally.
|
||||
Unknown part kinds now fail explicitly instead of inheriting gas ports/storage.
|
||||
Source whitespace checks pass. Existing user machine files were read and copied
|
||||
to temporary test locations; they were not migrated in place.
|
||||
620
docs/MACHINE_BUILDER.md
Normal file
620
docs/MACHINE_BUILDER.md
Normal file
@@ -0,0 +1,620 @@
|
||||
# 3D machine builder
|
||||
|
||||
Open **Machine Workshop** from the maximized main menu. Choose **New machine**, or
|
||||
select a saved machine and choose **Edit** or **Play**. Pressing **B** in the
|
||||
scenario browser opens a new design directly.
|
||||
|
||||
The builder supports general pneumatic circuits as well as engines. The two
|
||||
example benches provide a four-stroke engine and a piston vacuum pump. The
|
||||
parts picker uses cached 3D previews of the same meshes that appear in the world.
|
||||
|
||||
**Import engine layout** (I) transfers the existing 450 single, parallel twin,
|
||||
flat six and V8 H-pipe layouts into new editable documents. The importer preserves
|
||||
pipe routes and diameters, storage dimensions, bore/stroke, firing phases, cam
|
||||
profiles, valve dynamics, shaft calibration, fuel calibration and ECU settings.
|
||||
It inserts a visible carburetor or throttle into the final inlet segment and adds
|
||||
the appropriate ECU with control wires. The metering body and stub replace part
|
||||
of that inlet segment, preserving its total route length and gas volume.
|
||||
|
||||
## Build and configure
|
||||
|
||||
The workshop uses a dark editor layout with resizable, collapsible Library and
|
||||
Inspector panels. Drag their dividers to resize; widths and collapse states are
|
||||
saved locally. **Reset UI** restores the default panel layout. The **Library**
|
||||
offers categories, search and part previews; **Objects** lists the current parts.
|
||||
**Ctrl+F** focuses search. Long control names fit their available width and reveal
|
||||
their full text on hover. Property labels sit above their editable values.
|
||||
|
||||
The inspector tabs are **Part**, **Links**, **Setup**, **Checks**, **Meters** and **Sense**.
|
||||
The oscilloscope has a separate bottom dock; drag its upper edge to resize it.
|
||||
The minimum window size is 1280x720, with the layout reviewed at 1920x1080 too.
|
||||
Entering the workshop maximizes the Windows window; the application no longer
|
||||
shrinks when moving between the workshop, home menu and engine scenarios.
|
||||
|
||||
1. Pick a part on the left and click the world to place it. Placement uses the
|
||||
horizontal plane set by **Machine → Place Y mm**. Hold Shift for repeated placement.
|
||||
2. Select a placed part to open its properties. Right click a part for its context
|
||||
menu. Click the part name to rename it.
|
||||
3. **G** selects Move. Drag a colored X, Y, or Z handle to translate in world axes,
|
||||
or drag a selected body to move in the screen plane.
|
||||
**R** selects Rotate; drag a colored ring to rotate about that world axis.
|
||||
**X** toggles snapping. Translation uses the configured grid; rotation snaps to
|
||||
15 degrees. Numeric position and rotation fields also allow precise changes.
|
||||
4. Click a numeric property, type its value and press Enter. Escape cancels typing.
|
||||
The minus/plus buttons make incremental changes. Scroll over the inspector to
|
||||
reach additional properties.
|
||||
5. Select a part to reveal its ports, then drag between ports or click a port
|
||||
followed by its target. **C** optionally shows all ports. Neutral markers are
|
||||
bidirectional gas ports; at least one end of a gas connection must be a pipe.
|
||||
The pipe endpoint snaps to the other part. Blue pins connect existing ECU
|
||||
assignment wires; ECU outputs are filled and inputs hollow. The **Links** tab
|
||||
configures or disconnects connections. Use the Move gizmo to move a small
|
||||
boundary symbol whose port occupies its center. **Escape** cancels a wire;
|
||||
**G** returns to Move. Route mode prioritizes editing pipe points.
|
||||
After choosing the first port, hover the target: a blue preview shows the
|
||||
snapped pipe route or control wire; a red preview explains why it cannot connect.
|
||||
Previewing does not change the design. Clicking an invalid target leaves the
|
||||
first port selected so you can choose another target.
|
||||
6. **P** selects Route for a selected pipe. Use **Add path point**, select a point,
|
||||
then drag its X/Y/Z handles. The inspector edits the selected point's local
|
||||
coordinates. Disconnect an attached endpoint before moving it independently.
|
||||
Select a numbered point and edit **Point dia mm** to change its bore. Every
|
||||
point has its own diameter; adjacent values form a linear taper along that
|
||||
segment. This replaces the former inlet/outlet diameter fields.
|
||||
**Ctrl-click** near the selected pipe's centerline to insert a bend at that
|
||||
location, or right click a pipe and choose **Insert bend here**. The new point
|
||||
starts on the original route, preserving length and gas volume until moved.
|
||||
Length, the selected point's bore and volume are shown above the viewport while editing.
|
||||
|
||||
Alt+left drag or right drag on empty space orbits the view. Middle drag pans;
|
||||
the wheel zooms; Home fits the design and **F** focuses the selected part or group.
|
||||
Q selects without a transform tool.
|
||||
Shift-click adds/removes parts from a selection. Shift-drag on empty space selects
|
||||
a group; Ctrl+A selects the whole machine. Move and Rotate operate on that group.
|
||||
Ctrl+C copies the selected parts, their internal air/control connections and
|
||||
attached probes; Ctrl+V inserts a translated copy. Ctrl+D duplicates immediately.
|
||||
Connections to parts outside the copied group are not copied. Delete removes the
|
||||
selection. Ctrl+Z/Ctrl+Y undo/redo. The copied group remains available when loading
|
||||
another machine during the same workshop session. Right-clicking a selected part
|
||||
keeps the group selected and opens Copy, Paste, Duplicate and Save as assembly.
|
||||
|
||||
Click **Assemblies** to save the selected group for reuse or insert a saved
|
||||
assembly into the current design. Assemblies are stored in
|
||||
`machines/assemblies/` as `*.assembly.toml` files and can be copied between
|
||||
workspaces. Their internal connections, component settings, pipe profiles and
|
||||
attached probes are preserved. On insertion, part and probe IDs are remapped and
|
||||
the group is placed near the current selection (or the view center), selected and
|
||||
focused for positioning. The destination machine's shaft and simulation settings
|
||||
apply to the inserted components. Shaft gauges remain specific to each machine.
|
||||
|
||||
Escape cancels a placement/connection or clears selection; it never leaves the
|
||||
workshop. Use **Menu** to return to the scenario menu, or close the application
|
||||
window to exit.
|
||||
|
||||
In Move mode, grab an atmosphere symbol directly to select and drag it, or use
|
||||
its X/Y/Z handles. Its attached pipe endpoint follows the move. Atmosphere
|
||||
symbols remain selectable at the end of a wide pipe.
|
||||
|
||||
## Machine checks
|
||||
|
||||
Press **H** or click **Checks** to open the checks panel in Build or Test. Warnings
|
||||
identify cylinders without a connected fuel source, ECUs without assigned parts,
|
||||
and fixed throttles that are closed without an assigned ECU. Notes identify
|
||||
sealed pipe/cylinder ports and remind you to crank a stopped free shaft.
|
||||
|
||||
Click a check to select and focus its part. Use the Part or Connections tabs to
|
||||
configure it. Wheel or Previous/Next browses longer lists. Checks refresh after
|
||||
edits, undo and redo; they do not change or prevent testing a design. A sealed
|
||||
port can be intentional. Fuel checks establish wiring/plumbing reachability;
|
||||
they do not guarantee flow through valves, correct calibration or combustion.
|
||||
Unresolved sensor checks open the affected attachment in **Sense**.
|
||||
|
||||
Ideal junctions support multiple pipes on the same point. Connect free pipe ends
|
||||
to the junction's center marker. Connecting a third pipe to an occupied
|
||||
pipe-to-pipe join automatically replaces that join with a shared node. Switching
|
||||
to stored-volume mode gives branches distinct ports on the visible box; switching
|
||||
back to an ideal node brings them to its center.
|
||||
|
||||
## Physical dimensions
|
||||
|
||||
There is one design geometry shared by rendering, picking and simulation:
|
||||
|
||||
- **Pipes and reducers:** centerline segment lengths determine the simulated
|
||||
length. Each route point defines a diameter. Diameter varies linearly between
|
||||
adjacent points, allowing expansions, necks and reducers within one pipe. The
|
||||
finite-volume mesh integrates the exact frustum volumes across each cell,
|
||||
including cells that cross multiple profile segments. This preserves gas
|
||||
volume across mesh densities. Bends stay at their edited positions.
|
||||
Visual route points do not force extra simulation cells: the physics mesh follows
|
||||
total arclength at the selected cell density. A genuinely short pipe remains short
|
||||
and can require more CFL substeps.
|
||||
Sharp changes in area also constrain the timestep by the ratio between face
|
||||
area and the gas volume stored in a cell.
|
||||
- **Chambers and storage junctions:** the visible internal box dimensions define
|
||||
capacity as X × Y × Z. Rotation changes the port locations and appearance,
|
||||
and preserves volume.
|
||||
- **Cylinders and piston pumps:** bore, stroke and compression ratio define the
|
||||
gas cavity and clearance. The rendered piston and current simulated volume
|
||||
use the same displacement. Engine cylinders also expose valve diameters/counts,
|
||||
cam timing/lift, valve mass, spring, damping, preload, AFR and spark advance.
|
||||
- **Throttle and check valve bodies:** throttles use their visible elliptical
|
||||
tube dimensions for gas storage; check valves use their box dimensions.
|
||||
Diameter and discharge coefficient define the restriction. Throttles
|
||||
can use the test pedal or a fixed opening. Check valves pass flow from their
|
||||
local -X inlet to +X outlet above the configured cracking pressure.
|
||||
- **Ideal junctions:** characteristic and legacy modes have zero stored volume
|
||||
and are drawn as node symbols. The storage mode is drawn as a physical box.
|
||||
- **Carburetor:** the elliptical throat stores gas according to its length and
|
||||
Y/Z diameters. The restriction diameter controls flow. Pilot/main/needle jets
|
||||
meter liquid fuel from the float bowl into that gas; the solver transports it
|
||||
through pipes into the cylinders. Jets, needle corrections, fuel density, bowl
|
||||
capacity/refill, fuel film and discharge settings are editable.
|
||||
- **ECU:** a controller housing with no gas storage. Blue wires assign injection
|
||||
and ignition to cylinders, and throttle control to a throttle or carburetor.
|
||||
One ECU can control multiple parts. An unconnected ECU controls none.
|
||||
- **Pressure/atmosphere and end-cap symbols:** these represent boundary conditions
|
||||
and have no storage volume. A pressure boundary is an infinite reservoir with
|
||||
the configured pressure and temperature; an end cap reflects flow.
|
||||
|
||||
Moving, rotating or resizing a connected component updates the attached pipe
|
||||
endpoints. Its pipes therefore change length when their routes change. There is
|
||||
no pressure-driven visual expansion in this editor.
|
||||
|
||||
The flow equations remain one dimensional in pipes and lumped in chambers.
|
||||
The 3D layout supplies physical sizes and junction directions; this is not a 3D
|
||||
CFD mesh. Valve restrictions and zero-volume fittings use the existing boundary
|
||||
models. Ideal pressure-actuated check valves have no moving reed mechanics.
|
||||
|
||||
## Test and return to editing
|
||||
|
||||
Press **Tab** or **Test** to compile the design and run it with audio and pressure
|
||||
colors. Select a part to read its current pressure and volume. W/S or the pedal
|
||||
bar changes throttle; hold V for starter torque on a free shaft. Space pauses
|
||||
or resumes both physics and audio. Test offers **1x, 0.5x, 0.25x, 0.1x and 0.01x**
|
||||
playback; Page Down halves the speed and Page Up doubles it (1% to 100%).
|
||||
**Step 10 ms** advances a paused machine by exactly 0.01 simulated seconds.
|
||||
The physics timestep and engine calibration stay the same at every playback speed;
|
||||
audio pitch slows with playback. Buffered audio scales with speed to keep controls
|
||||
responsive. The four existing pipe solvers and five audio
|
||||
environments are selectable under **Machine** before starting a test.
|
||||
|
||||
New engine benches use a free shaft at zero initial RPM. Hold **Hold to start**
|
||||
or V to crank. A cylinder only receives fuel from an explicitly assigned injection
|
||||
ECU or from fuel physically delivered through a connected carburetor. There is no
|
||||
implicit cylinder injector. A carburetor can run with mechanical ignition; an
|
||||
optional ignition/idle ECU can control spark and opening.
|
||||
|
||||
For EFI, connect the ECU's blue output to each cylinder's blue input. Connect
|
||||
that output to the throttle's blue input for idle/throttle control. ECU settings
|
||||
include idle target/opening limits, PI gains, ignition-map points, cruise/power
|
||||
AFR, throttle response, overrun cuts, rev limit, resume RPM and minimum cut time.
|
||||
Fuel and spark cuts are independent. A cut lasts at least its configured time and
|
||||
resumes only below the resume RPM. Carburetor ECUs use **Ignition / idle only**
|
||||
so they do not add an invisible second fuel source.
|
||||
|
||||
The **Mech** category contains **Shaft / crankshaft**, **Flywheel**, **Drive motor**
|
||||
and **Brake / load**. Connect their gold mechanical ports. Cylinders and piston
|
||||
pumps also have a gold shaft port; each must attach to a named shaft before Test.
|
||||
Moving a shaft changes its attachment lines without moving its cylinders or pipes.
|
||||
|
||||
Each shaft has independent angle, initial speed, base inertia and friction.
|
||||
Flywheels add inertia once; the shaft inspector shows the connected total.
|
||||
Motors can apply signed torque or impose an ideal target speed. The ideal speed
|
||||
drive reports the work it supplies or absorbs, including speed changes. A brake
|
||||
applies opposing torque and drag, dissipates work and cannot drive a stopped shaft
|
||||
backward. Pure mechanical machines can run without any gas pipes.
|
||||
|
||||
Use **Setup** to choose the primary readout shaft, or pin it from a shaft's
|
||||
inspector. Each ECU's controlled cylinders must share a shaft. Engine timing is
|
||||
currently forward-only; standalone mechanical shafts support reverse rotation.
|
||||
The legacy starter calibration remains on migrated shafts until explicit starter
|
||||
parts are added. New shafts have no hidden starter torque by default.
|
||||
|
||||
The saved **Shaft, flywheel and brake bench** demonstrates acceleration, motor
|
||||
power and absorbed load power. Its file is `machines/shaft-drive-bench.machine.toml`.
|
||||
|
||||
The overlay distinguishes **SPEED CONTROLLED**, **TORQUE DRIVEN**, **FREE SHAFT**, **FUEL OR SPARK CUT**,
|
||||
**VALVE FLOAT**, and **NO FUEL SOURCE**. The model has no fixed 16,000 RPM cap.
|
||||
With limiting disabled, controlled checks ran at 16,000, 20,000 and 24,000 RPM
|
||||
without positivity repairs. The unloaded bench settled around 17,500 RPM as net
|
||||
torque fell toward zero. This depends on geometry, valve dynamics, fuel/spark
|
||||
calibration and losses; increasing a rev limit does not guarantee engine torque
|
||||
at that speed.
|
||||
|
||||
Unconnected gas ports on pipes and cylinders are sealed; the status line reports
|
||||
their count. Unused tank faces are simply walls. Unconnected mechanical attachments
|
||||
receive a navigable check and prevent Test.
|
||||
Invalid dimensions, duplicate connections and physically separated connected
|
||||
ports are rejected. Numerical failures stop the test and report an error.
|
||||
|
||||
Press **Tab** again to stop playback and return to the same design and camera.
|
||||
Starting another test initializes a fresh gas and mechanical state.
|
||||
|
||||
## Live tuning
|
||||
|
||||
Properties are editable during Test unless marked **BUILD** and dimmed. Live changes
|
||||
are validated and synchronized between physics batches, preserving gas state,
|
||||
shaft state, simulation time, carburetor fuel film and controller history. Carb
|
||||
jets, needle corrections, fuel settings, ECU calibration, fixed throttle opening,
|
||||
check-valve cracking pressure, cylinder combustion settings, shaft losses,
|
||||
motor torque/target speed and brake loads can be adjusted live. Live edits support undo/redo and saving,
|
||||
and remain in the design when returning to Build.
|
||||
|
||||
Pipe routes/diameters, transforms, part dimensions, bore/stroke, cam profiles and
|
||||
valve mechanics, connections, initial gas conditions, solver/mesh settings and
|
||||
compiled boundary coefficients, initial shaft states and shaft/flywheel inertia
|
||||
require Build. A live undo that would change one
|
||||
of these properties is rejected without changing the running machine.
|
||||
|
||||
## Instruments and traces
|
||||
|
||||
Press **M** or **Meters**. Select a part, then choose **Pressure**, **Temp**, **Flow**,
|
||||
**AFR**, **Lambda** or **Charge AFR**. **RPM**, **Torque** and **Power** target the
|
||||
selected shaft or attached rotational part; without a compatible selection they
|
||||
use the declared primary shaft. Up to eight channels can be attached.
|
||||
Probes add no gas volume or restriction. Colored P1/P2 markers locate probes in
|
||||
the world; clicking one in Select or Test opens its instrument settings.
|
||||
Use **< Probe / Probe >** to choose the channel plotted on the scope.
|
||||
|
||||
Pipe taps use a fraction of arclength from Start to End, adjustable in the panel.
|
||||
Measurements use the cell containing the tap, so spatial detail follows mesh
|
||||
resolution. Lumped-part flow is net flow into the part; pipe flow is signed
|
||||
Start-to-End. Pressure is absolute kPa, temperature K, flow g/s, torque Nm and
|
||||
power kW. Motor gauges report drive torque/work, brakes report signed reaction
|
||||
torque and positive absorbed power, and new shaft gauges report net acceleration
|
||||
torque and kinetic power. Migrated legacy output gauges retain their running
|
||||
averages of engine output after shaft friction, excluding bench drive and load;
|
||||
the inspector identifies that measurement mode.
|
||||
|
||||
**Bulk AFR and lambda** track fuel origin as a conserved passive tracer, including
|
||||
fuel already burned. Exhaust readings therefore travel with the gas through
|
||||
pipes, chambers and shared junctions and mix according to transported mass.
|
||||
Put a downpipe probe after a collector to read the combined exhaust there.
|
||||
The exhaust starts as clean air, so downstream readings can remain very lean
|
||||
until the original air has been purged by exhaust gas.
|
||||
Lambda is bulk AFR divided by the probe's stoichiometric reference (default 14.7).
|
||||
Changing the reference changes the displayed ratio, not the combustion chemistry.
|
||||
**Charge AFR**, available on cylinders, reads current unburned fresh-air mass
|
||||
divided by remaining unburned fuel mass. It becomes undefined after that fuel is
|
||||
used. No fuel is shown as **--**, rather than an invented stoichiometric reading.
|
||||
These are ideal model instruments; sensor heating, response delay and detailed
|
||||
oxygen chemistry are not simulated.
|
||||
|
||||
The scope samples at 2,756.25 Hz in simulation time and retains the latest 32,768
|
||||
samples (about 11.9 simulated seconds). Choose a 0.05, 0.2, 1 or 5 second display
|
||||
window; the graph shows min/max/mean and preserves peaks when compressing samples
|
||||
onto screen pixels. Frequencies above half the sampling rate cannot be resolved.
|
||||
The scope stays open while switching to Part settings, allowing live carb/ECU
|
||||
tuning while watching a trace. X closes the scope. Space freezes the machine for
|
||||
inspection. The last capture remains available after returning to Build.
|
||||
|
||||
**Export captured CSV** writes simulation timestamps and labeled channel units to
|
||||
`verification/measurements/`; undefined readings are empty cells. **Clear captured
|
||||
trace** starts a new capture without resetting the machine. Adding/removing a
|
||||
channel or changing a tap/reference clears the capture to keep its schema
|
||||
consistent. Instruments can be configured during Test without reloading physics.
|
||||
|
||||
## Saving
|
||||
|
||||
Ctrl+S saves the design. A new design asks for a file name; Ctrl+Shift+S saves a
|
||||
separate copy. Files use the versioned `*.machine.toml` format in `machines/`
|
||||
relative to the working directory, or `FLUIDSIM_MACHINE_DIR` when set. Coordinates
|
||||
and dimensional values are stored in SI units; inspector fields show their units.
|
||||
|
||||
Open/Saved files refreshes the library and provides separate Edit and Play
|
||||
buttons. The scenario browser discovers saved machines each time it opens.
|
||||
The document is validated before saving, and the written temporary file is read
|
||||
back through the same loader before replacement. Unsaved changes prompt before
|
||||
opening a different design, returning to the menu or closing the window.
|
||||
|
||||
Version 2 adds explicit fuel/control parts, control wires, imported port locations,
|
||||
cam profiles and shaft losses. Version 1 files still load, but cylinders no longer
|
||||
receive automatic fuel; add a connected carburetor or injection ECU to an older
|
||||
engine design. The importer currently supports four-stroke layouts with uniform
|
||||
ducts. Two-stroke crankcase/scavenge assemblies and profiled silencers are not yet
|
||||
imported. Existing engine TOML files retain their separate schema.
|
||||
|
||||
Version 3 stores `point_diameters_m`, one diameter in metres per `points_m` entry,
|
||||
for each pipe. Version 1 and 2 pipes load with their former linear taper sampled
|
||||
at each point's arclength, preserving the original shape and volume. Saving writes
|
||||
version 3. Inserted points inherit the interpolated diameter; moving a point keeps
|
||||
its assigned diameter. Undo/redo and duplicated assemblies retain the full profile.
|
||||
Version 4 also saves instrument channels, tap positions and lambda references.
|
||||
Versions 1 through 3 still load. Recorded samples are exported separately as CSV.
|
||||
|
||||
Version 5 saves connection ports and methods by stable names instead of enum
|
||||
indices. Custom chamber ports retain paired coordinate/key lists. Versions 1
|
||||
through 4 migrate on load, including legacy aliases of a shared ideal junction.
|
||||
Saving writes version 5; existing files are not rewritten until explicitly saved.
|
||||
Port descriptors now identify gas, rotational, translational, Boolean, Number
|
||||
and legacy ECU-assignment domains. Only implemented parts appear in the picker;
|
||||
declaring a domain does not create its future components.
|
||||
|
||||
Version 6 adds explicit shafts and mechanical configurations. Loading versions 1
|
||||
through 5 creates a visible shaft and any required speed drive/load, preserves
|
||||
cylinder phase offsets and retargets legacy shaft instruments. Saving writes
|
||||
version 6 with named mechanical connections; no existing file is changed merely
|
||||
by loading it. Copy/paste and assemblies retain internal mechanical attachments
|
||||
and instrument targets; external attachments remain unresolved.
|
||||
|
||||
Version 7 adds sensor attachments with stable IDs and named kinds, host/reference
|
||||
bindings, normalized pipe taps and crank offsets. Part/sensor ID counters survive
|
||||
save/load so deleted identities are not reused. Versions 1 through 6 still load.
|
||||
Version 8 also saves a separate typed logic graph with stable node/pin keys,
|
||||
parameters, references and independent canvas positions. Saving writes version 8;
|
||||
loading alone does not rewrite an existing file.
|
||||
|
||||
## Sensor attachments
|
||||
|
||||
Open `machines/sensor-bench.machine.toml` for a ready-to-run demonstration of all
|
||||
six sensor assemblies, a filling tank, fixed valve and independently driven shaft.
|
||||
|
||||
Select a physical part and open **Sense**. Only supported sensor buttons are
|
||||
enabled: pressure/MAP and gas temperature/IAT on gas storage, pipes and environment
|
||||
boundaries; differential pressure on two such taps; signed total mass flow on a
|
||||
pipe; crank speed/position on an explicit shaft; TPS on a throttle or carburetor.
|
||||
Right-click a pipe and choose **Add sensor here...** to use the clicked arclength
|
||||
location. Otherwise a new pipe sensor starts at 50%. Move its tap in Sense.
|
||||
|
||||
For differential pressure, add the positive tap first. Select the reference
|
||||
physical part, return to Sense, switch to **Reference (-) tap** and choose
|
||||
**Bind reference to selected part**. The output is positive pressure minus
|
||||
reference pressure, in pascals. Absolute pressure outputs use pascals absolute;
|
||||
temperature uses kelvin; total mass flow uses kg/s, positive from pipe Start to
|
||||
End. A volume has no unique through-flow direction, so flow sensors require a pipe.
|
||||
|
||||
The ideal crank pickup supplies signed angular speed in rad/s, crank angle in
|
||||
0..2pi radians and a Boolean synchronized output. The pickup's configurable
|
||||
reference offset applies to its angle, while the runtime retains the shaft ID
|
||||
and unwrapped crossings. Zero speed while stopped is valid. This is an ideal
|
||||
pickup, without tooth acquisition or cam-phase inference. TPS reports mechanism
|
||||
opening fraction, including the existing ECU throttle response, rather than
|
||||
substituting pedal demand for a fixed opening.
|
||||
|
||||
Sensors sample once per simulation audio sample and display sample time and
|
||||
validity during Test. Number outputs have filled green dots; Boolean outputs have
|
||||
filled red dots. Attached sensors introduce no gas storage or flow resistance,
|
||||
and do not use any of the eight recorded meter channels. Use their Logic
|
||||
references to wire sensor-driven ECUs; the labeled legacy ECU retains its
|
||||
compatibility behavior.
|
||||
|
||||
Click an S-numbered marker to inspect its sensor. Previous/Next browses all
|
||||
attachments, including unresolved ones. To rebind, select a compatible physical
|
||||
part, reopen Sense and choose **Bind host to selected part**. Deleting a host
|
||||
keeps the sensor and its former host label; missing/incompatible bindings show
|
||||
invalid outputs rather than ordinary zero readings. Copy/paste and assemblies
|
||||
remap internal sensor hosts; omitted external references remain unresolved.
|
||||
Creation, binding, pickup offsets and removal require Build and support undo/redo.
|
||||
|
||||
These sensors measure gas temperature and total mass flow. Fresh-air MAF, cam
|
||||
phase, lambda and material/head temperature sensors follow their own roadmap
|
||||
stages and physical models.
|
||||
|
||||
## Logic editor and runtime
|
||||
|
||||
`machines/logic-compressor-bench.machine.toml` demonstrates an automatic air
|
||||
supply: tank pressure feeds below-threshold hysteresis (120/150 kPa absolute),
|
||||
an AND gate combines demand with a master switch, and a command node owns the
|
||||
compressor motor's enable field. A numeric control supplies angular speed. The
|
||||
saved defaults run the demonstration from Test. Choose **Logic** or **Split**
|
||||
in the workspace toolbar to inspect its graph beside the physical machine.
|
||||
|
||||
The Logic library offers node categories, search and an Objects list. Pick a node,
|
||||
then click the canvas to place it. Drag node headers to arrange the graph; Shift
|
||||
and a selection rectangle select groups. Middle drag pans, wheel zooms about the
|
||||
pointer, Home fits the active view, and F focuses its selection. Switching views
|
||||
keeps the camera, canvas navigation, selection and running simulation. In Split,
|
||||
click the desired pane to choose its navigation/inspector context.
|
||||
|
||||
Drag between compatible pins, or click a pin and then its target. Inputs are
|
||||
hollow rings; outputs are filled circles. Boolean is red and Number green. One
|
||||
output can fan out. Starting from a connected input rewires it atomically;
|
||||
rejection leaves the original connection intact. Right-click a pin to disconnect
|
||||
its wires. Escape cancels a pending connection. The library filters compatible
|
||||
node kinds while a pin is pending, and a newly placed numeric node adopts that
|
||||
pin's quantity/reference. Other numeric schemas are configured in the inspector.
|
||||
|
||||
Select a node to edit its name, parameters, quantity and pressure reference.
|
||||
Numbers use the units shown on their fields/pins (SI internally). Enter applies
|
||||
a text edit; Escape cancels it. Invalid text remains editable. Hover pins for
|
||||
type, direction, units, validity, supported ranges and required/default metadata.
|
||||
Combinational-cycle candidates highlight their cycle nodes and report the path.
|
||||
H opens diagnostics; clicking a signal warning reveals the affected node.
|
||||
|
||||
Create sensors in 3D **Sense**, then choose **Show Logic** to reveal their reference.
|
||||
The **Command** button on supported physical parts creates/reveals a command
|
||||
reference. Its inspector chooses a command function and compatible physical
|
||||
target. Selecting a sensor/command node selects that physical host; **Focus in
|
||||
3D** opens Split and focuses the host. Reference nodes do not add gas storage or
|
||||
duplicate a physical sensor/actuator.
|
||||
|
||||
During Test, node topology, schemas, bindings, control ranges and initial memory
|
||||
require Build. Safe constants, thresholds and canvas layout remain editable.
|
||||
Click switch controls, hold momentary buttons, drag sliders, or type into numeric
|
||||
controls on their nodes. Paused control changes queue for the next physics sample;
|
||||
their control widgets identify queued values while pin values stay frozen. Momentary
|
||||
buttons release on focus loss, leaving Logic, or stopping Test. Manual Test inputs
|
||||
do not silently overwrite their configured initial states. Readouts show compact
|
||||
UI-sampled trends; recorded meter channels and CSV export remain separate.
|
||||
|
||||
Ctrl+C/V copies nodes and internal wires; Ctrl+D duplicates, Delete removes, and
|
||||
Ctrl+Z/Y undo/redo. Same-document sensor copies reference the same sensor entity.
|
||||
Copies pasted into another document detach external sensor references and keep
|
||||
their labels. Pasted command nodes require an explicit new target so they cannot
|
||||
take over an existing actuator field accidentally. Copying physical assemblies
|
||||
remaps their internal sensors, command targets and connected control harnesses.
|
||||
|
||||
The graph runtime supports typed controls, Boolean gates, comparisons, arithmetic,
|
||||
mapping, selectors, sensors, commands, displays and explicit one-tick delays.
|
||||
Each audio sample evaluates the graph before physics and holds commands during
|
||||
fluid substeps. Unknown types/ports, incompatible quantities, multiple drivers
|
||||
and combinational cycles are rejected. Required unconnected inputs block Test.
|
||||
Missing measurements and math faults carry invalid status; motor/valve commands
|
||||
apply their declared inactive fallback rather than retaining a previous command.
|
||||
|
||||
The graph is saved in `[logic]` tables, including independent canvas positions.
|
||||
Physical inspector controls identify signal-owned fields; existing manual or ECU
|
||||
controls cannot simultaneously own them. Existing engine ECUs retain their
|
||||
labeled legacy compatibility model; new sensor-driven presets use explicit
|
||||
measurements as described below.
|
||||
|
||||
## Sensor-driven ignition and Alpha-N EFI
|
||||
|
||||
Open `sensor-ignition-bench.machine.toml` or `sensor-alpha-n-bench.machine.toml`
|
||||
from saved machines for a working 3000 RPM test bench. The first uses a physical
|
||||
carburetor; the second transports fuel from an attached intake injector. Both
|
||||
have crank-to-ECU wiring visible in **Logic**. Press Test to run, then inspect
|
||||
inputs and physical parts in Logic/3D or Split.
|
||||
|
||||
Select an ECU and cycle its preset between **Legacy ideal**, **Crank ignition**
|
||||
and **Alpha-N EFI**. Sensor presets assign only cylinders through blue links;
|
||||
choose **Show ECU connector in Logic** and connect crank speed and angle from
|
||||
one physical pickup. Alpha-N also requires a throttle-position sensor. No driver
|
||||
demand wire is needed for a manually adjusted throttle. The cylinder's **Pickup
|
||||
TDC deg** is its calibrated TDC in the sensor's wrapped crank coordinates; for a
|
||||
pickup offset of zero, a cylinder physical phase of 90 degrees has TDC at 270.
|
||||
Crank-only ignition is wasted spark, without hidden cam/cycle synchronization.
|
||||
|
||||
For EFI, select an intake chamber and choose **Attach fuel injector**, then
|
||||
choose its Alpha-N fuel ECU. Scroll its properties to set injector capacity,
|
||||
initial fuel mass, fuel temperature, finite/ideal rail supply and cylinder-dose
|
||||
count. A common-plenum injector can deliver several cylinder doses; their total
|
||||
must match the ECU's assigned cylinders. The ECU's **Fuel RPM**, **Fuel TPS** and
|
||||
**Fuel cell** fields edit the calibrated table (RPM rows, TPS columns, displayed
|
||||
mg per cylinder per four-stroke cycle). This is an open-loop calibration, not a
|
||||
measurement of trapped air mass.
|
||||
|
||||
Required input faults, stale or mixed pickups, and pickup/engine shaft mismatch
|
||||
cut new spark events and EFI delivery. Constants and arithmetic cannot substitute
|
||||
for physical crank timing. Previously ignited fuel finishes its physical burn.
|
||||
The ECU inspector reports sensor faults and rev limiting; injector hosts report
|
||||
flow and remaining fuel. Ignition maps and RPM limits allow live tuning. Fuel
|
||||
maps, injector setup, strategy and pickup calibration require Build.
|
||||
|
||||
Fuel delivery currently uses a declared regulated rail and continuous/batch-average
|
||||
vaporized fuel addition into the gas volume. There is no liquid rail-pressure
|
||||
sensor or sequential pulse-width/evaporation model yet. Format 9 saves the new
|
||||
bindings and calibrations; older machines continue loading without being rewritten.
|
||||
|
||||
## Speed-density EFI
|
||||
|
||||
Open `sensor-speed-density-bench.machine.toml` for the minimal MAP/IAT engine
|
||||
example. Select **Speed-density EFI** in the ECU preset cycle and choose **Show
|
||||
ECU connector in Logic**. Connect crank speed/position, an absolute-pressure MAP
|
||||
pickup in the intake manifold and a gas-temperature IAT pickup at the intake.
|
||||
TPS is not required for this basic preset. Missing or invalid measurements cut
|
||||
fuel and new ignition events; the controller does not read hidden cylinder charge.
|
||||
|
||||
In the ECU properties, **SD displacement cc** is the calibrated swept volume per
|
||||
cylinder. **SD target AFR**, air gas constant and the VE table determine the
|
||||
open-loop estimate `MAP * displacement * VE / (R_air * IAT)`. **SD VE RPM** gives
|
||||
the rows and **SD VE MAP kPa** the columns; **SD VE cell %** edits row-major table
|
||||
values. Values outside the table axes use its edge calibration. These settings
|
||||
require Build; the ignition map and rev limiter still permit live tuning.
|
||||
|
||||
Injector attachments can target Alpha-N or speed-density ECUs. Their explicit
|
||||
capacity and finite/ideal rail supply continue to limit actual delivered fuel.
|
||||
The dry ideal-gas estimate does not measure fresh-air fraction under intake-gas
|
||||
dilution and currently has no warm-up, acceleration or lambda corrections. Format
|
||||
10 saves this strategy and its calibration while preserving loading of versions
|
||||
1..9.
|
||||
|
||||
## Architecture and further work
|
||||
|
||||
The proposed expansion is documented in the
|
||||
[machine systems roadmap](MECHANICAL_BUILDER_PLAN.md): typed ports and logic,
|
||||
explicit shafts, pneumatic parts, sensor attachments and sensor-driven ECU
|
||||
presets, with one integrated delivery order. See the linked implementation
|
||||
progress for completed parts and the remaining planned features.
|
||||
|
||||
`MachineDocument` stores parts with stable IDs, local geometry and explicit port
|
||||
connections. `MachineSimulation` compiles that document into the existing
|
||||
`Pipe1D`, `Volume0D`, `FourStrokeCylinder`, `BoundarySystem`, `Solver` and
|
||||
`MechanicalSystem` components.
|
||||
Shaft, ECU, cam, combustion and fuel-metering updates run once per audio sample,
|
||||
as in the original engine scenarios. Gas transport and pressure-operated check
|
||||
valves retain CFL substeps. Compiled part/gate lists avoid repeated graph scans.
|
||||
The editor consumes snapshots while testing and owns its document separately.
|
||||
Pipe meshes are cached until their local route or diameters change; transforms
|
||||
and pressure colors reuse the mesh. Part icons share a cached texture atlas.
|
||||
|
||||
The editor includes fifteen part types, gas and mechanical example circuits, four preset imports,
|
||||
save/load, group transforms, pipe routing, shared nodes, undo/redo, selection
|
||||
copy/paste, reusable assemblies, and Build/Test. Remaining workshop work is
|
||||
focused on port-picking and control-wire visibility in dense assemblies, clearer
|
||||
group-edit feedback, property help, and adjustable port placement. New machine
|
||||
types, gears and clutches remain on the component roadmap; explicit shafts are implemented.
|
||||
|
||||
### Next session
|
||||
|
||||
Continue the active component goal: explicit shafts, flywheels, drives, brakes
|
||||
and six basic sensor assemblies are implemented and verified. The typed logic
|
||||
runtime, persistence, physical commands and basic Logic/split editor are integrated
|
||||
with thirteen focused suites passing at the editor milestone. Sensor-driven
|
||||
ignition/Alpha-N presets, grouped ECU inputs, pickup calibration and explicit
|
||||
injector attachments are now integrated, saved and tested with actual combustion
|
||||
in two new benches. Speed-density EFI now adds a third working bench with actual
|
||||
MAP/IAT inputs, calibrated VE/displacement, saved bindings and workshop controls.
|
||||
Fifteen focused suites pass. Continue stage 4 with physical
|
||||
needle/shutoff/regulator/relief valves and pressure-switch control; see the next
|
||||
physical component slice in the progress file. Keep advanced graph UX/operator panels on the UI
|
||||
backlog. See [component progress](COMPONENT_IMPLEMENTATION.md). Preserve the
|
||||
existing uncommitted working tree and user-edited machine files.
|
||||
|
||||
### Historical session notes
|
||||
|
||||
The user asked to stop polishing and launch the current build on 2026-10-30.
|
||||
The updated desktop executable was built and launched with
|
||||
`machines/single-450-imported.machine.toml`. Changes are saved locally and remain
|
||||
uncommitted. Preserve the existing working tree.
|
||||
|
||||
Continue with small usability refinements, particularly port picking and control
|
||||
wire visibility in dense assemblies, group-edit feedback, and property help.
|
||||
The 2026-10-31 usability pass added validated connection previews, click-position
|
||||
bend insertion, focus selection and navigable machine checks. It also corrected
|
||||
the OpenGL/SFML state handoff so controls remain visible after raw 3D rendering.
|
||||
If the reported 16,000 RPM stop persists, reproduce it with the user's exact
|
||||
machine/settings: controlled tests passed through 24,000 RPM and the free bench
|
||||
settled around 17,500 RPM, so a universal 16,000 RPM cap was not reproduced.
|
||||
Legacy version 1 engine files need an explicit carburetor or injection ECU now.
|
||||
|
||||
Workshop performance was fixed on 2026-10-30. Arclength meshing, sample-rate
|
||||
control updates, compiled gate lists and cached rendering reduced flat6 simulation
|
||||
cost from 104.4 to 8.8 us/sample at 3,000 RPM and 35% pedal. It now uses 98 cells
|
||||
and approximately one CFL substep/sample, matching the native layout's mesh.
|
||||
The importer positions its metering body to allow a longer outlet stub while
|
||||
preserving the inlet endpoints, total route length and gas volume. Untouched stock
|
||||
imports were refreshed; custom saved designs retain their authored geometry.
|
||||
Flat6 and V8 audio-driven playback kept real time with no measured underruns.
|
||||
See the [performance report](../verification/performance/2026-10-30/workshop-report.md)
|
||||
for timings, validation and reproducible commands. Further work can focus on the
|
||||
usability items above and closer calibration parity with native engine scenarios;
|
||||
the workshop still represents its metering hardware as explicit editable parts.
|
||||
|
||||
## Checks
|
||||
|
||||
`fluidsim_machine_tests` covers centerline/profile volumes, rotated tanks,
|
||||
connection snapping, malformed graphs, file round trips, closed/open throttle
|
||||
behavior, shared nodes, group transforms, explicit fuel sources, ECU cut duration,
|
||||
carburetor-only combustion, preset imports, vacuum pumping and an EFI bench.
|
||||
`fluidsim_machine_builder_tests` checks projection and picking, world rotations,
|
||||
mouse-driven translation, Shift selection, assembly duplication/deletion, undo
|
||||
and Build/Test transitions. Assembly checks cover connected subset copies,
|
||||
air/control endpoint remapping, geometry and probe persistence, cross-document
|
||||
paste, library saving/browsing/insertion, and insertion undo/redo. Escape and
|
||||
explicit Menu/window exit paths are checked separately.
|
||||
Its `--preview <directory>` option writes editor screenshots for layout review.
|
||||
It also checks pipe-cache invalidation, transform/pressure cache reuse, deleted
|
||||
parts and the icon atlas. Physics checks cover route-knot-independent meshing,
|
||||
real short-pipe CFL limits and pump sample-rate refinement.
|
||||
Additional checks cover non-mutating connection previews, incompatible air/control
|
||||
pins, occupied ports and collapsed pipes; shared fuel-source diagnosis; perspective
|
||||
bend insertion with undo/redo; and check-to-part navigation. Preview mode also
|
||||
verifies rendered UI pixels after both scene and icon rendering.
|
||||
Instrument checks cover probe persistence, exhaust AFR/lambda after combustion,
|
||||
conserved fuel-origin transport through solvers and junctions, pause/step/slow
|
||||
playback, live tuning without a reload, locked geometry, live undo/redo and CSV
|
||||
channel units. The [instrument verification report](../verification/performance/2026-10-31/workshop-instruments.md)
|
||||
records the playback checks with eight channels active.
|
||||
`fluidsim_machine_tests --rpm-sweep` checks high RPM and a 30-second free-running
|
||||
acceleration trace with the ECU rev limiter disabled.
|
||||
621
docs/MECHANICAL_BUILDER_PLAN.md
Normal file
621
docs/MECHANICAL_BUILDER_PLAN.md
Normal file
@@ -0,0 +1,621 @@
|
||||
# Machine systems roadmap: mechanics, pneumatics, logic and ECU sensors
|
||||
|
||||
Planning draft, 2026-11-01. This describes proposed work, not implemented features.
|
||||
Implementation has started; see [component progress](COMPONENT_IMPLEMENTATION.md)
|
||||
for completed foundations and the remaining delivery backlog.
|
||||
|
||||
## Goal
|
||||
|
||||
Build machines from explicit gas, mechanical and signal connections. Engines,
|
||||
pumps and actuators exchange physical work; sensors measure named locations;
|
||||
logic and ECUs control declared targets. The workshop should make those paths
|
||||
easy to connect, inspect and diagnose.
|
||||
|
||||
Originally cylinders and piston pumps used one implicit machine-wide crankshaft.
|
||||
Explicit shaft, flywheel, drive and brake parts are now implemented, with migrated
|
||||
legacy layouts and targeted instruments. Six basic sensor assemblies and their
|
||||
physical bindings/live inspector are also implemented. The basic logic runtime
|
||||
and 3D/Logic/Split editor, sensor-driven ignition/Alpha-N presets and explicit
|
||||
intake injector attachments are integrated and verified. Stage 4 now also has a
|
||||
working speed-density preset using actual MAP/IAT signals and calibrated VE.
|
||||
Advanced graph UX, the stage-4 pneumatic valves and the later physical systems
|
||||
remain on the delivery backlog.
|
||||
|
||||
## Agreed design rules
|
||||
|
||||
- Boolean signals are red; Number signals are green. Directed inputs are hollow
|
||||
rings and outputs filled circles. Physical ports such as gas/pressure remain
|
||||
bidirectional and use distinct neutral domain symbols.
|
||||
- Establish typed ports, stable identities and validation before adding parts
|
||||
that depend on them. Types must exist in the document and runtime, not only UI.
|
||||
- Use one machine document across 3D Build, Logic and Test. Canvas references,
|
||||
sensors and instruments do not create duplicate physical machines.
|
||||
- Sensor attachments belong at named physical locations. Readouts may fan out
|
||||
to ECUs, logic and instruments. Controllers use connected measurements and
|
||||
explicit calibration/defaults rather than hidden access to unrelated state.
|
||||
- Provide simple starting presets with few mandatory connections; expand their
|
||||
visible optional inputs as features are enabled. Missing and invalid data
|
||||
have explicit, different behavior.
|
||||
- Preserve legacy machines through versioned migrations and labeled compatibility
|
||||
controllers. Keep their operating behavior reviewable before changing models.
|
||||
- UI work prioritizes consistent appearance, responsive layout and preventing
|
||||
text/control overlap at the primary 1920x1080 desktop target. Test starts running
|
||||
in the existing workspace; panels resize/collapse and retain their widths.
|
||||
|
||||
## Integrated delivery order
|
||||
|
||||
This is the authoritative implementation order. The track sections below detail
|
||||
their own parts and acceptance targets; their local numbering is not a separate
|
||||
release schedule.
|
||||
|
||||
| Stage | Deliverable | Completion example |
|
||||
| --- | --- | --- |
|
||||
| 0 | Shared port descriptors, quantity/reference types, stable keys, validation and migration scaffolding | Every connection path rejects the same incompatible types; existing machines still load. |
|
||||
| 1 | Explicit shafts, flywheel, drive/load, basic sensor attachments and targeted instruments | Two independent engines; one shaft driving a loaded piston pump. |
|
||||
| 2 | Logic canvas, manual controls, gates, thresholds, live values and valve/motor command ownership | Sensor -> threshold -> enable switch -> drive, with red/green signal wiring. |
|
||||
| 3 | Sensor-driven ignition ECU and Alpha-N EFI, crank sensing and ECU connector UI | Carbureted ignition control and minimal EFI both run from explicit sensors; missing crank stops controlled combustion. |
|
||||
| 4 | MAP/IAT sensors, speed-density ECU, regulator/relief/shutoff valves and pressure-switch preset | Sensor-driven engine and automatic regulated air supply bench. |
|
||||
| 5 | Gears, clutch, starter; directional valves, linear actuators and loads; stateful logic | Geared pump, clutched load and timed pneumatic actuator circuits. |
|
||||
| 6 | MAF/sequential ECU options, air motor, rotary compressor, acoustic assemblies and remaining UI refinement | Stored-air motor and richer sensor/actuator examples. |
|
||||
| Later | Thermal parts, belt detail, ejector, turbine/turbocharger and general linkages | Add only with a defined physical model and acceptance cases. |
|
||||
|
||||
Ship small usable slices within each stage. Stage 3 depends on defining sensor
|
||||
timing and explicit fuel delivery, not merely drawing extra ECU input pins.
|
||||
|
||||
## Proposed parts
|
||||
|
||||
| Part | Purpose and editable properties | Delivery |
|
||||
| --- | --- | --- |
|
||||
| Shaft / crankshaft | Named rotational node; angle, initial RPM, base inertia and friction. Cylinders and pumps attach with individual phase offsets. | First release |
|
||||
| Flywheel | Adds inertia to its connected shaft; explicit inertia initially, with geometry-derived inertia as a later option. | First release |
|
||||
| Drive motor | Ideal speed-controlled bench drive or torque-controlled motor; target speed or torque and direction. Report required drive torque and power in speed mode. | First release |
|
||||
| Brake / load | Applies a constant opposing torque and optional speed-dependent drag; reports absorbed power. | First release |
|
||||
| Gear pair | Fixed signed speed ratio between two shafts, with an explicit efficiency/loss model. Tooth counts determine the ratio. | Second release |
|
||||
| Clutch | Connects two shafts through a torque-limited friction coupling; engagement, torque capacity, slip and dissipated power. | Second release |
|
||||
| Starter | Torque-limited drive with a one-way coupling and hold-to-start input. Initially uses a specified torque curve. | Second release |
|
||||
| Belt / pulley drive | Same-direction or crossed drive with pulley ratio; an ideal fixed-ratio model first, slip/tension later. | Later |
|
||||
| Rotary compressor / pump | Continuous shaft load coupled to gas flow; requires a defined displacement or performance-map model. | Later |
|
||||
|
||||
Existing cylinders and piston pumps gain mechanical attachment ports. Their
|
||||
phase is relative to the attached shaft. A shaft can have multiple attachments;
|
||||
there is no automatic connection because parts happen to be nearby.
|
||||
|
||||
For the initial release, one shaft part represents a rigid rotational node.
|
||||
Its visible length helps placement; it does not introduce torsional flexibility.
|
||||
Base shaft inertia and attached flywheel inertia are counted once. Separate
|
||||
shafts rotate independently until connected by a supported coupling.
|
||||
|
||||
## Connection and simulation rules
|
||||
|
||||
- Distinguish gas ports, control pins and rotational ports by shape, label and
|
||||
color. Preview compatibility and explain rejected connections before clicking.
|
||||
- Mechanical attachment lines identify the shaft association. They do not
|
||||
silently move cylinders or imply a physically supported bent driveshaft.
|
||||
- Keep shaft angle/speed per shaft. Route cylinder torque, pump torque, losses,
|
||||
drives and loads to the correct shaft; sample its RPM for each attached ECU.
|
||||
- Initially require cylinders controlled by one ECU to share a shaft. Diagnose
|
||||
mixed-shaft assignments until explicit speed-reference selection is added.
|
||||
- Ideal gears impose a speed/angle relationship. Solve connected rigid groups
|
||||
with inertia reflected through their ratios; do not use competing independent
|
||||
RPM updates or stiff penalty torques to approximate a rigid connection.
|
||||
- Validate contradictory ratios, incompatible prescribed speeds and unsupported
|
||||
constraint loops. Start with tree-shaped gear networks and reject loops with
|
||||
actionable diagnostics until a general constraint solver is available.
|
||||
- Friction and brake torque oppose motion, and must not push a stopped shaft
|
||||
into reverse. Drive torque can select direction. Check existing cylinder/cam
|
||||
assumptions before enabling reverse engine operation.
|
||||
- Track external work, rotational kinetic energy and coupling losses. Clutch
|
||||
slip dissipates energy; an ideal prescribed-speed drive supplies or absorbs
|
||||
whatever work is required and reports it.
|
||||
- Connection changes and initial state changes require Build. Safe torque,
|
||||
speed-target and engagement tuning can remain available during Test.
|
||||
|
||||
## Workshop UI
|
||||
|
||||
The [workshop UI rework](WORKSHOP_UI_PLAN.md) specifies the workspace layout,
|
||||
interaction conventions and user-selected preferences in more detail.
|
||||
|
||||
### First release: connecting and understanding the machine
|
||||
|
||||
- Searchable parts picker with Gas, Engine, Mechanical and Control categories.
|
||||
- Connection filters for Gas, Mechanical and Control; fade unrelated links.
|
||||
- Larger screen-space port targets, hover labels, endpoint highlighting and
|
||||
candidate cycling where ports overlap. Preserve existing gas connection rules.
|
||||
- Mechanical overview listing each shaft, its attached parts and its drive/load.
|
||||
Selecting a row selects and focuses the corresponding assembly in 3D.
|
||||
- Shaft inspector groups Initial state, Inertia and losses, and Connections.
|
||||
Show total connected inertia separately from editable base inertia.
|
||||
- Cylinder/pump inspector shows the named shaft and phase offset, with an action
|
||||
to select the shaft. Unattached mechanical parts receive a navigable check.
|
||||
- RPM, torque and power instruments require a named target. Torque readings
|
||||
identify what is measured, such as cylinder output, motor input or load torque;
|
||||
a shaft network has no single universal transmitted-torque value.
|
||||
- Test overlay shows the selected shaft's RPM and operating state. With several
|
||||
shafts, let the user pin the main readout instead of silently choosing one.
|
||||
|
||||
### Second release: coupled machines and editing feedback
|
||||
|
||||
- Gear connection preview shows input/output shafts, ratio and rotation direction.
|
||||
- Clutch control and instruments show engagement, slip RPM and lost power.
|
||||
- A compact power-path view highlights the selected shaft's connected network.
|
||||
- Group inspector shows selection count, pivot and mixed property values;
|
||||
offer only edits applicable to the selected parts.
|
||||
- Add concise property help with units, physical meaning and Build/Test status.
|
||||
- Add named saved visibility groups for dense assemblies. Hiding a part only
|
||||
affects editing visibility, never simulation membership.
|
||||
|
||||
Adjustable gas port placement is a separate follow-up. Ports represent physical
|
||||
openings, so editing them must update connected geometry and preserve validation;
|
||||
they should not be freely dragged as purely cosmetic connection markers.
|
||||
|
||||
## Save files, imports and assemblies
|
||||
|
||||
- Introduce a new machine format version with explicit shaft references and
|
||||
mechanical connections, using stable IDs.
|
||||
- Upgrade older files to one visible shaft with their existing inertia, losses,
|
||||
initial speed, drive and load settings. Create drive/load parts as needed and
|
||||
attach all existing cylinders and pumps with their original phase offsets.
|
||||
- Preserve existing starter behavior during migration; replace it with an
|
||||
explicit starter when the starter model is available.
|
||||
- Retarget legacy shaft instruments to the migrated shaft. Retain the existing
|
||||
torque metric's meaning and label it explicitly during the transition.
|
||||
- Engine imports create explicit mechanical parts. Copy/paste and assemblies
|
||||
remap internal shaft references and retain external attachments as visibly
|
||||
unresolved; they must never attach to a destination shaft by coincidence.
|
||||
|
||||
## Mechanical track scope and acceptance
|
||||
|
||||
Before these implementation stages, deliver the typed port foundation described
|
||||
in the logic section below. Mechanical and pneumatic additions should use that
|
||||
shared contract from their first implementation.
|
||||
|
||||
1. **Explicit shaft foundation:** document/serialization migration, independent
|
||||
shaft runtime state, cylinder/pump attachment, rendering and selection.
|
||||
Acceptance: two disconnected engines can run at different speeds; existing
|
||||
imported machines retain their operating behavior and phase relationships.
|
||||
2. **Usable mechanical bench:** flywheel, drive motor and brake/load; connection
|
||||
filters, port picking, shaft inspector and targeted instruments.
|
||||
Acceptance: assemble and inspect an engine driving a loaded piston pump;
|
||||
disconnected attachments and fuel/control problems are easy to locate.
|
||||
3. **Coupled shafts:** fixed gears, clutch and explicit starter, with ratio and
|
||||
power-path feedback. Acceptance: accelerate a second inertia through a clutch,
|
||||
observe slip losses, and drive a pump at a different speed through gears.
|
||||
4. **Broader machines:** belt drives and a rotary compressor/pump after choosing
|
||||
and validating their physical models; adjustable gas ports and visibility
|
||||
groups as workshop follow-ups.
|
||||
|
||||
Validation should cover legacy migration, assembly ID remapping, independent
|
||||
shafts, phase continuity, inertia response, gear direction/ratio and reflected
|
||||
inertia, prescribed-drive work, clutch losses and stop/reversal behavior.
|
||||
Use focused simulation and builder checks as each stage is implemented.
|
||||
|
||||
The first mechanical slice covers the shaft foundation and usable bench above.
|
||||
Detailed electrical motors,
|
||||
batteries, gear contact dynamics, shaft flex and belt tension need separate models
|
||||
and are outside that milestone.
|
||||
|
||||
## Pneumatic and fluid-powered expansion
|
||||
|
||||
Extend the current compressible-gas system with useful circuits: store compressed
|
||||
air, regulate it, switch it, and convert its pressure into linear or shaft work.
|
||||
Existing chambers already serve as finite tanks, and the piston pump can form a
|
||||
reciprocating compressor with external check valves. Add useful presets and
|
||||
assemblies for these before introducing redundant part types.
|
||||
|
||||
This roadmap initially targets gas. Liquid hydraulics would need a separately
|
||||
defined fluid model, including compressibility, cavitation and appropriate
|
||||
boundary treatment, before the same UI could promise hydraulic behavior.
|
||||
|
||||
### Proposed fluid parts
|
||||
|
||||
| Part | Behavior and properties | Priority |
|
||||
| --- | --- | --- |
|
||||
| Adjustable restriction / needle valve | Two-port metering with editable opening and effective flow area; supports reverse flow and choking through the shared gas boundary model. | A: basic circuits |
|
||||
| Shutoff / solenoid valve | Two-port on/off valve; normally open/closed state, commanded opening and finite actuation time. | A |
|
||||
| Pressure relief valve | Opens from a protected node to an explicit outlet above a differential-pressure setting; reseat behavior prevents chatter. | A |
|
||||
| Pressure regulator | Reduces downstream pressure using a bounded inlet restriction with response dynamics; cannot increase pressure or hold a target without supply capacity. Non-relieving initially. | A |
|
||||
| Pressure switch | Senses a chosen pressure and emits a Boolean signal with separate on/off thresholds; starts/stops a compressor or operates a valve. | A |
|
||||
| Directional valve | 3/2 first, then 5/2; named supply, work and exhaust ports, explicit internal paths and transition behavior. | B: actuators |
|
||||
| Pneumatic actuator | Single-acting spring-return first, then double-acting; bore, rod, stroke, moving mass, friction, dead volumes and end stops. | B |
|
||||
| Linear load / spring | Opposing force, attached mass, spring and damping for an actuator's translational port. | B |
|
||||
| Quick-exhaust valve | Vents an actuator locally through its own outlet when the supply path depressurizes. | B |
|
||||
| Air motor | Positive-displacement gas motor with inlet, outlet and shaft port; displacement, leakage and mechanical losses. | C: shaft power |
|
||||
| Rotary compressor | Shaft-driven gas compression with displacement/map, leakage and losses; load arises from gas work. | C |
|
||||
| Nozzle / venturi | Converging/diverging pipe profile preset first; a later ejector adds a suction port and a validated entrainment/mixing model. | C |
|
||||
| Filter / porous restriction | Calibrated pressure loss and optional gas storage; acoustic attenuation requires an additional defined model. | C |
|
||||
| Silencer / resonator | Pipe/chamber assemblies first: expansion chamber, side branch and Helmholtz-style resonator. Absorptive silencers need an explicit loss model. | C |
|
||||
| Cooler / heated chamber | Defined wall temperature or thermal mass and gas-wall heat transfer; report heat flow alongside gas state. | Later |
|
||||
| Turbine / turbocharger | Map-based turbine and compressor on explicit shaft nodes; requires performance data and operating-range handling. | Later |
|
||||
|
||||
Priority letters describe the pneumatic track, not a promise to finish it before
|
||||
the mechanical milestones. Prioritize A alongside the shaft foundation, B next,
|
||||
and C once independent shafts and component coupling are established.
|
||||
|
||||
### Physical behavior and scope
|
||||
|
||||
- Valves transfer conserved mass, species and energy through actual ports.
|
||||
A regulator must not overwrite a chamber pressure to enforce its setpoint.
|
||||
- Supply and exhaust are visible connections. An unconnected outlet remains
|
||||
sealed under the current builder rule; warn when a valve needs an exhaust path.
|
||||
- Regulator and switch inspectors state whether settings are absolute, gauge or
|
||||
differential pressure. Store physical pressures in SI and define the reference.
|
||||
- Actuator force uses the pressures on both piston faces and their effective
|
||||
areas. Each chamber volume follows piston motion, with positive dead volume;
|
||||
gas work and mechanical work must balance.
|
||||
- Add translational mechanical ports for actuators and linear loads. An actuator
|
||||
is distinct from the existing crank-driven piston pump. A general linkage
|
||||
mechanism can connect translation to rotation in a later milestone.
|
||||
- Resolve moving valves, actuator stops and small chambers with appropriate
|
||||
component timesteps. The existing pipe CFL limit alone is insufficient.
|
||||
- Air motors and compressors exchange torque/work with the attached shaft.
|
||||
Their models define starting, stall, reversal and off-design behavior rather
|
||||
than extrapolating efficiency formulas through zero speed.
|
||||
- Pressure switches and solenoids use typed signal ports. Add a manual switch
|
||||
and a simple hysteretic controller; define their update order. Keep ECU
|
||||
assignments intact while extending the control connection system.
|
||||
|
||||
### Fluid UI and instruments
|
||||
|
||||
- Add Supply/storage, Valves, Actuators, Rotating machines and Thermal/acoustic
|
||||
subcategories, plus searchable saved circuit assemblies.
|
||||
- Label valve ports P (supply), A/B (work), and R/S (exhaust), with tooltips in
|
||||
plain language. Show connected paths and moving valve state during Test.
|
||||
- Preview actuator stroke limits and current rod position in 3D. Its inspector
|
||||
shows chamber pressures, net force, position and velocity.
|
||||
- Add differential pressure, actuator force/travel/velocity, valve state and
|
||||
switch output instruments. Flow probes must identify the measured port and
|
||||
direction; show mass flow by default and label volumetric reference conditions.
|
||||
- Highlight a selected supply path and its exhaust route; distinguish blocked
|
||||
flow from disconnected parts. Add checks for missing exhausts, absent supply,
|
||||
unattached mechanical loads and incompatible signal connections.
|
||||
- Show regulator target beside actual pressure, tank stored gas state, and
|
||||
absorbed/delivered shaft power. Keep invalid or out-of-range readings explicit.
|
||||
|
||||
### Playable circuit milestones
|
||||
|
||||
1. **Air supply bench:** existing piston pump + check valves + tank, pressure
|
||||
switch, relief valve and regulator. Charge a finite tank, cycle the drive
|
||||
between pressure thresholds and feed a restricted downstream branch.
|
||||
2. **Actuator bench:** regulated supply + 3/2 valve + spring-return actuator;
|
||||
follow with a 5/2 valve and double-acting actuator driving a linear load.
|
||||
3. **Stored-air motor:** charge the tank, disconnect the compressor drive, and
|
||||
run an air motor connected to a flywheel and brake until pressure falls.
|
||||
4. **Acoustic bench:** interchangeable resonator/silencer assemblies with inlet
|
||||
and outlet pressure traces and source captures. Compare pressure loss and
|
||||
acoustic response at consistent operating points.
|
||||
5. **Boost system:** shaft compressor first, then a map-based turbine/compressor
|
||||
assembly with intake and exhaust connections once those models are ready.
|
||||
|
||||
Acceptance checks should include sealed-system mass/energy accounting, choking
|
||||
and reverse flow, relief opening/reseating, regulator response and insufficient
|
||||
supply, switch hysteresis, valve port states, actuator force/work and end stops,
|
||||
and motor/compressor gas-work-to-shaft-work balance. These belong to the future
|
||||
implementation stages; this document adds planning only.
|
||||
|
||||
## Typed signals and logic nodes
|
||||
|
||||
### Confirmed current implementation
|
||||
|
||||
The shared typed-port foundation, physical sensor attachments and a general
|
||||
signal-graph runtime are now implemented. `MachineDocument::logic` stores nodes
|
||||
and named typed wires separately from physical connections. Runtime nodes include
|
||||
controls, gates, thresholds, numeric operations, sensor/command references,
|
||||
readouts and explicit delays. See [component progress](COMPONENT_IMPLEMENTATION.md)
|
||||
for the verified scope. The basic Logic canvas, shared 3D/Logic/split workspace,
|
||||
typed wire editing, interactive controls and graph inspectors are now integrated.
|
||||
Advanced graph UX, operator panels and the later component stages remain planned.
|
||||
|
||||
`MachineConnectionKind::Control` remains legacy ECU assignment. Those blue wires
|
||||
assign a controller; the new Boolean/Number graph does not reinterpret them.
|
||||
|
||||
### Shared port contract: implement first
|
||||
|
||||
- Give each port an explicit domain: Gas, Rotational, Translational, Boolean,
|
||||
Number, or the existing ECU assignment domain during migration. Domain and
|
||||
signal direction must be data, not inferred from a part kind or rendered color.
|
||||
- Define ports through a shared descriptor with a stable key, display name,
|
||||
domain, input/output direction for signals, cardinality, and typed defaults.
|
||||
Number ports also declare quantity/unit, supported range and any required
|
||||
pressure reference. Physical ports retain their domain-specific behavior.
|
||||
- Use stable serialized port keys, versioned migration for old numeric indices,
|
||||
and stable part/node kind keys. Adding a port or reordering an enum must not
|
||||
silently reconnect an existing saved machine to a different function.
|
||||
- Store signal values as Boolean or finite numeric values with explicit validity.
|
||||
Do not encode Booleans as arbitrary floating-point values or use NaN as truth.
|
||||
- Only connect matching signal types from output to input. One output may feed
|
||||
multiple inputs; one input accepts one driver. No automatic Bool/Number cast.
|
||||
Explicit conversion nodes handle thresholding and Boolean-to-number selection.
|
||||
- For numbers, also validate quantity compatibility. Pressure, RPM and opening
|
||||
fraction must not connect interchangeably because all are numeric. Store SI
|
||||
values; present convenient units, and require explicit scaling/mapping where
|
||||
a quantity changes. Enforce arithmetic quantity rules and configure constants
|
||||
with a quantity. Infer polymorphic node ports consistently when compiling.
|
||||
- Required unwired inputs produce a navigable check and block Test; optional
|
||||
inputs show a declared default in the UI. Invalid sensor/math output remains
|
||||
visibly invalid and follows a documented per-consumer fallback; actuator
|
||||
command inputs default to a declared inactive state, not a previous command.
|
||||
- Use the same descriptors and compatibility checks for connection previews,
|
||||
actual edits, loading, validation, compilation and runtime binding. Keep
|
||||
signal links out of gas connectivity walks, pipe snapping and fuel searches.
|
||||
- Preserve existing ECU assignments as their own relation; migrate them through
|
||||
explicit adapters or dedicated ports when individual ECU commands are exposed.
|
||||
Never reinterpret old ECU links as Boolean or Number links.
|
||||
|
||||
### Color and node editing
|
||||
|
||||
- **Boolean ports and wires: red. Number ports and wires: green.** These hues
|
||||
identify the type consistently in 3D, node view, picker, inspector and legend.
|
||||
- **Inputs are hollow rings; outputs are filled circles.** Apply this convention
|
||||
consistently to directed ports on every node and part in both 3D and Logic
|
||||
views. Shape communicates direction; red/green communicates Boolean/Number
|
||||
type. Labels and hover text identify both type and direction without color.
|
||||
- Physical ports without an input/output direction, such as pressure/gas
|
||||
connections, remain bidirectional and use a distinct neutral physical connector
|
||||
symbol. Their appearance must not imply one-way flow. A pressure sensor's gas
|
||||
connection is bidirectional; its numeric measurement output is a filled green
|
||||
circle. Rotational and translational attachments likewise use their physical
|
||||
domain symbol rather than suggesting a fixed power-flow direction.
|
||||
- Bright/dim styling and true/false text show Boolean state while retaining the
|
||||
red type color and input-ring/output-circle silhouette. Number hover labels
|
||||
show value, units and validity.
|
||||
- Replace green as the gas identity with neutral gas markers. Connection validity
|
||||
uses a separate checkmark or cross/message so success/error feedback cannot be
|
||||
mistaken for a signal type or input/output direction.
|
||||
- Add a **Logic** view: a pannable, zoomable node canvas for the same machine.
|
||||
Logic nodes have saved canvas positions; sensors and actuators appear as
|
||||
references to their existing physical parts, not duplicated simulated parts.
|
||||
- Inputs appear on the left, outputs on the right. Drag to connect, branch from
|
||||
outputs, reroute wires, disconnect and undo. Filter compatible nodes when
|
||||
dragging from a pin. Label incompatible targets with a rejection reason.
|
||||
- Keep 3D part placement separate from canvas layout. Selecting a sensor or
|
||||
actuator reference can focus its physical part, and vice versa. Logic nodes
|
||||
need no gas storage or physical 3D housing to function.
|
||||
- Add live pin values, Boolean state and compact number traces during Test.
|
||||
Pause freezes logic with physics; stepping advances both. A control panel can
|
||||
pin buttons, switches, sliders and readouts for use without opening the graph.
|
||||
|
||||
### Initial node library
|
||||
|
||||
| Group | Nodes | Defined behavior |
|
||||
| --- | --- | --- |
|
||||
| Manual Boolean controls | Toggle switch, momentary button, Boolean constant | Switch retains its state; button is true while held. Release on focus loss, view close or Test stop. |
|
||||
| Manual numeric controls | Number constant, slider, numeric input | Quantity, unit, range and initial value are explicit; user edits arrive at simulation boundaries. |
|
||||
| Boolean gates | AND, OR, NOT, XOR, NAND, NOR | AND/OR start with two inputs and can add inputs; named, deterministic truth tables. |
|
||||
| Threshold and comparison | Greater than, less than, equal within tolerance, range/window, hysteresis threshold | Number inputs produce Boolean outputs; comparisons use compatible quantities. Hysteresis has separate on/off thresholds and an initial state. |
|
||||
| Numeric arithmetic | Add, subtract, multiply, divide, min, max, absolute value, clamp | Compatible quantity rules; division by zero produces an invalid value and a diagnostic. |
|
||||
| Mapping and selection | Gain/offset, map range, Boolean-controlled number selector, Bool-to-number | Selector chooses between two compatible numbers; Bool-to-number emits configured false/true values. Mapping declares quantities and clamping behavior. |
|
||||
| State and timing | Set/reset latch, rising/falling edge, on-delay, off-delay, pulse timer | Reset wins if set and reset coincide. Timers use simulation time. Edge nodes initialize from their input without emitting a false startup edge. |
|
||||
| Number memory | One-tick delay, sample-and-hold | Explicit initial value; held quantities remain typed. Delay separates feedback across ticks. |
|
||||
| Sensors | Pressure, differential pressure, temperature, mass flow, shaft RPM, actuator position, limit switch | Attach to a physical target/port without changing its gas volume. Supply validity and quantity metadata. |
|
||||
| Outputs and commands | Valve enable, valve opening, motor enable/target, clutch engagement, starter command | Each target exposes its supported type, units/range and fallback. Explicit manual, signal or existing-controller ownership prevents competing commands. |
|
||||
| Displays | Boolean lamp, numeric readout, trace | Observe a typed signal without changing its evaluation. |
|
||||
|
||||
Starter nodes are switches, buttons, constants/sliders, AND/OR/NOT, thresholds
|
||||
with hysteresis, number selection, pressure/RPM sensors, command inputs and
|
||||
displays. Add the rest after the core graph and timing semantics are established.
|
||||
|
||||
### Evaluation, feedback and persistence
|
||||
|
||||
- Initially evaluate the compiled signal graph once per audio sample, using the
|
||||
configured sample period (currently typically 1/44100 s). Sample physical
|
||||
sensors at the boundary, evaluate the graph, latch actuator commands, then
|
||||
advance physics. Fluid CFL substeps hold those commands. Slower playback does
|
||||
not change control time relative to simulated time.
|
||||
- Topologically evaluate combinational nodes. Treat memory/delay outputs as
|
||||
stored state, then commit their next state simultaneously after evaluation.
|
||||
Reject combinational cycles with a highlighted cycle path; explicit state
|
||||
permits feedback. Physical sensor/actuator feedback crosses a physics step.
|
||||
- Consume manual-input events in order at a simulation boundary. Preserve a
|
||||
press/release pair between boundaries for one tick; edge outputs last one
|
||||
tick, and delayed output validity/initialization is defined explicitly.
|
||||
- Save graph topology, port keys, node parameters, canvas layout and configured
|
||||
initial states. Each fresh Test resets to those initial states; transient
|
||||
button presses, timer state and latch state do not silently become defaults.
|
||||
- Topology edits require Build. Allow validated constants, thresholds and manual
|
||||
controls during Test at synchronized boundaries. Copy/paste and assemblies
|
||||
remap internal references and expose unresolved external sensor/target bindings.
|
||||
|
||||
### Foundation acceptance and delivery order
|
||||
|
||||
1. **Typed ports:** shared descriptors, typed links, stable serialization keys,
|
||||
validation and legacy migration. Confirm all declared domains are represented
|
||||
before mechanical and pneumatic nodes depend on them.
|
||||
2. **Basic logic:** red Boolean and green Number pins/wires, node canvas, manual
|
||||
controls, gates, thresholds, sensors, commands and live values.
|
||||
3. **Stateful control:** timers, latch, edge detection, memory and numeric mapping,
|
||||
followed by reusable control assemblies and pinned control panels.
|
||||
|
||||
Required implementation checks: Bool-to-Bool and Number-to-compatible-Number
|
||||
connections succeed; Bool/Number mismatches, quantity mismatches, reversed
|
||||
direction and multiple drivers fail consistently in preview, load and compile.
|
||||
Also verify fan-out, defaults, unknown port keys, save/load and copy/paste,
|
||||
ECU migration, invalid math, graph ordering, cycle rejection, delayed feedback,
|
||||
pause/step behavior and momentary-button release. Core runtime checks now cover
|
||||
types, wiring validation, evaluation, persistence, faults, delayed feedback and
|
||||
button-event release/reset. Canvas interactions, live inspector edits, focus/view-
|
||||
close release, shared selection, pause/step and split layouts now have interaction
|
||||
and rendered checks. Pinned operator panels and the remaining UI refinements still
|
||||
need their own acceptance work.
|
||||
|
||||
First demonstration: pressure sensor -> hysteresis threshold -> AND with a
|
||||
manual enable switch -> compressor drive enable. Show tank pressure as a green
|
||||
Number wire and compressor demand as a red Boolean wire. Follow with a button
|
||||
and timer driving a pneumatic valve, and a slider commanding a proportional
|
||||
valve through an explicit range mapping.
|
||||
|
||||
## Sensor-driven ECUs
|
||||
|
||||
### Current behavior and intended boundary
|
||||
|
||||
The current `Ecu::step` receives pedal and the implicit shaft RPM directly.
|
||||
`Ecu::fuel_for_air` doses fuel from cylinder air mass, and cylinder code supplies
|
||||
the timing context. There are no general wired MAP, TPS, crank or IAT inputs.
|
||||
Existing AFR/lambda instruments are ideal transported fuel-origin measurements,
|
||||
not oxygen-sensor or heater models.
|
||||
|
||||
Retain a labeled **Legacy ideal ECU** for migrated machines until equivalent
|
||||
sensor-driven examples are established. New ECU presets use explicit sensor
|
||||
inputs, controller calibration and declared actuator targets. Do not disguise
|
||||
direct cylinder-air-mass access as speed-density or MAF fuel estimation.
|
||||
|
||||
### ECU family and minimum connections
|
||||
|
||||
Use one shared ECU runtime with selectable strategy/capability presets. Different
|
||||
picker entries can provide the desired ECU types without duplicating validation,
|
||||
sensor binding and fault handling. Keep calibration separate from measurements.
|
||||
|
||||
| ECU preset | Required measurements in its basic mode | Optional features and their added requirements |
|
||||
| --- | --- | --- |
|
||||
| Ignition / carburetor | Crank speed and crank position | MAP or TPS for load-based spark; TPS/driver demand and a commanded throttle for electronic idle. Fuel remains physically supplied by the carburetor. |
|
||||
| Alpha-N EFI | Crank speed/position and throttle position (TPS) | IAT, engine temperature and barometric corrections; lambda trim; cam phase for sequential operation. Basic mode uses a calibrated RPM/TPS fuel table. |
|
||||
| Speed-density EFI | Crank speed/position, manifold absolute pressure (MAP), intake air temperature (IAT) | TPS for acceleration enrichment/overrun logic, temperature warm-up correction, barometric correction, lambda trim and cam phase. Requires displacement and volumetric-efficiency calibration. |
|
||||
| MAF EFI | Crank speed/position and fresh-air mass flow | TPS, MAP, temperature corrections, lambda trim and cam phase. Requires a stated manifold-filling/charge-allocation model; inlet flow is not automatically trapped cylinder charge. |
|
||||
| Advanced / custom | Required set of the explicitly selected base strategy | Configure supported feature modules; each declares added sensors, targets and fallback behavior. Initially no arbitrary scripting. |
|
||||
|
||||
All presets also need declared controlled cylinders/ignition targets; EFI needs
|
||||
explicit enabled fuel-delivery targets. These are configuration/actuator bindings,
|
||||
not extra sensor inputs. Engine geometry, firing order, trigger offset, fuel
|
||||
properties and maps are calibration, not wires.
|
||||
|
||||
Basic crank-only modes use supported batch injection and wasted-spark or
|
||||
distributor timing. Four-stroke sequential injection/individual-cycle ignition
|
||||
requires cam phase or another explicit cycle-synchronization source. Do not
|
||||
silently read the simulated cylinder's 720-degree phase to remove that requirement.
|
||||
|
||||
Temperature, lambda, TPS and other optional inputs are not universally mandatory.
|
||||
Features activate their requirements: no TPS means no TPS-based enrichment;
|
||||
no lambda means open-loop fueling. Fixed reference values are editable and labeled
|
||||
where a preset supports them. Required base-strategy inputs cannot silently fall
|
||||
back to hidden physics values. A manual throttle can be used without driver-demand
|
||||
or throttle-actuation wires; electronic throttle adds its own demand/feedback needs.
|
||||
|
||||
The strategy split follows the Speeduino manual's MAP/speed-density and
|
||||
TPS/Alpha-N distinction. MegaSquirt documentation provides trigger/cam examples
|
||||
for batch/wasted-spark versus sequential operation. The exact minimum sets above
|
||||
are proposed simulation presets, not a claim about a particular hardware ECU.
|
||||
|
||||
### Sensor placement and representation
|
||||
|
||||
| Sensor | Attachment / physical representation | Outputs and scope |
|
||||
| --- | --- | --- |
|
||||
| Crank position/speed | Attach to a named shaft, with visible pickup marker and reference offset | Number speed and crank angle; explicit synchronized/valid status. One sensor assembly supplies both. |
|
||||
| Cam phase | Attach to an explicit cam drive when available; initially a configured phase pickup associated with the engine shaft | Number cycle phase and Boolean synchronized status. The initial pickup is an ideal cycle-reference abstraction, not simulated cam gearing. |
|
||||
| MAP / pressure | Tap a pipe at normalized arclength or attach to a chamber/plenum | Number absolute pressure. Select the manifold downstream of the throttle; support per-runner or configured combined readings for individual throttles. |
|
||||
| Differential pressure | Reference two named gas taps | Number pressure difference with labeled positive/reference sides. |
|
||||
| IAT / gas temperature | Pipe tap or chamber gas attachment | Number temperature; ideal gas reading first, optional lag later. |
|
||||
| TPS | Attach to throttle/carburetor mechanism | Number actual opening fraction, distinct from commanded opening or pedal demand. |
|
||||
| Driver demand / pedal | Standalone manual/control part | Number demand fraction; optional for manual-throttle engines. |
|
||||
| MAF | Pipe tap for an ideal mass-flow sensor; later an inline body if its restriction/storage is modeled | Number signed fresh-air mass flow, with declared reverse-flow behavior. Preserve total-flow instruments as a separate quantity. |
|
||||
| Lambda | Exhaust pipe/collector attachment | Number lambda plus validity. Start with a labeled ideal mixture tracer; defer wideband oxygen chemistry, heater and response modeling. Closed-loop use requires explicit opt-in to that approximation. |
|
||||
| Engine/head temperature | Attach to a cylinder/head thermal node when implemented | Number material temperature. Until then use a declared fixed calibration input; cylinder gas temperature is not coolant/head temperature. |
|
||||
| Barometric pressure | Standalone ambient sensor or tap of an explicitly selected environment boundary | Number absolute pressure; never substitute manifold pressure during running. |
|
||||
| Fuel pressure | Attach to a fuel rail/circuit once one exists | Number pressure; until then injector pressure is a declared calibration, not a pretend sensor reading. |
|
||||
| Knock, oil pressure, battery voltage | Future dedicated physical models | Expose only after their state/model exists; do not synthesize plausible readings from unrelated gas variables. |
|
||||
|
||||
Sensors are saved entities with stable IDs and host references; their Logic view
|
||||
nodes refer to those same entities. Store pipe locations by arclength fraction,
|
||||
not mesh cell index, and chamber/shaft targets by stable identity. Placement must
|
||||
survive mesh rebuilds, transforms and valid geometry edits. Invalid/deleted hosts
|
||||
leave visible unresolved bindings rather than silently retargeting another part.
|
||||
|
||||
Use **Add sensor** on the selected part or pipe location, then offer only supported
|
||||
measurements. Attached taps do not consume a gas connection, create storage or
|
||||
alter flow. A standalone hose-connected pressure sensor has a bidirectional gas
|
||||
port and real hose/dead volume if included; its Number output is still a filled
|
||||
green circle. Flow sensors that add a body must explicitly add its losses/volume.
|
||||
|
||||
Each sensor exposes measured quantity, units, location, sample time, validity and
|
||||
optional response/filter settings. Ideal sensors come first; lag, noise, range
|
||||
limits and fault injection are later options with deterministic initialization.
|
||||
Existing probes may share sampling code; ECU inputs must not depend on the
|
||||
instrument UI's eight-channel limit or its lower-rate recording samples.
|
||||
|
||||
### Numeric timing inputs and synchronization
|
||||
|
||||
ECU measurement inputs are Number rings, including speed, crank angle, MAP,
|
||||
TPS and temperature. Crank speed and position are separate typed quantities,
|
||||
not a single overloaded number. Optional Boolean sync/run/kill inputs are red.
|
||||
|
||||
An ideal crank pickup outputs phase in a declared 360-degree reference frame and
|
||||
speed from the same timestamp. Cam reference resolves the four-stroke cycle.
|
||||
Keep unwrapped rotation/crossing information in the timing subsystem so wraparound
|
||||
or a crossing within one simulation step cannot lose an ignition/injection event.
|
||||
Validate coherent sensor provenance and phase offsets against controlled shafts.
|
||||
A constant RPM or an unrelated shaft sensor is not sufficient timing authority.
|
||||
The declared ideal timing channel may supply synchronization metadata alongside
|
||||
the numeric reading; it is not an invisible additional physics-state lookup.
|
||||
|
||||
Raw Hall/VR tooth waveforms and missing-tooth decoders are a later fidelity mode.
|
||||
They need timestamped edge delivery and a decoder that outputs these same typed
|
||||
measurements; sampling a Boolean once per audio sample must not be assumed to
|
||||
capture every tooth at high speed. General logic may process speed and angle,
|
||||
but transformations used for timing must preserve or explicitly invalidate the
|
||||
phase/time reference. Ordinary stale or incoherent values cause a visible fault.
|
||||
|
||||
### Actuation and fuel estimation
|
||||
|
||||
- EFI computes fuel demand from the selected sensor strategy and calibration.
|
||||
Account explicitly for speed-density charge estimates, Alpha-N table meaning,
|
||||
or MAF manifold filling before assigning fuel to cylinders.
|
||||
- Initially allow labeled per-cylinder ideal injector targets with explicit fuel
|
||||
mass commands and delivery timing. Fuel mass/energy enters through that modeled
|
||||
injection boundary; do not call the old `fuel_for_air` path behind the new ECU.
|
||||
Separate injector/rail parts can replace that abstraction later.
|
||||
- Expose per-cylinder fuel dose, spark advance and enable/cut commands with clear
|
||||
units and scheduling semantics. Numeric advance is an angle before TDC, not an
|
||||
arbitrary instantaneous spark pulse. Cylinder phase offsets remain calibrated
|
||||
mechanical relationships used by the timing scheduler.
|
||||
- ECU outputs and standalone logic must not simultaneously drive an actuator
|
||||
input. Use an explicit selector/command owner. Multiple sensor consumers are
|
||||
allowed. Each ECU initially controls cylinders on one crankshaft.
|
||||
- Loss of required crank synchronization cuts its controlled fuel and spark;
|
||||
missing optional lambda disables trim. Define preset-specific responses to MAP,
|
||||
IAT, TPS and MAF faults. Never automatically switch fuel strategy unless an
|
||||
explicit configured fallback has the required sensors and calibration.
|
||||
|
||||
### ECU connector UI and diagnostics
|
||||
|
||||
- Show Required, Connected and Optional input groups, with expandable Timing,
|
||||
Air/load, Temperatures, Mixture and Auxiliary sections. Keep every connected
|
||||
pin visible; show more optional pins on demand to avoid a wall of connectors.
|
||||
- Every input displays required/optional status, quantity/unit, current value,
|
||||
source sensor and any active fallback. Feature selection updates requirements
|
||||
immediately; hiding a pin does not disconnect it or disable its validation.
|
||||
- Add an ECU setup checklist: choose preset, assign cylinders/shaft, place required
|
||||
sensors, connect measurements, assign actuators, verify timing and enable Test.
|
||||
Offer sensor creation at a selected physical location; never guess a plenum.
|
||||
- Highlight a sensor's physical marker and its wire when selecting an ECU input.
|
||||
Show named ports and connection lists for dense ECU layouts; support reroutes
|
||||
and saved harness groups without merging distinct signals into an untyped bus.
|
||||
- Diagnose missing input, invalid measurement, synchronization loss, wrong shaft,
|
||||
incorrect measurement kind/location, target conflicts and uncalibrated maps.
|
||||
A valid zero RPM while stopped is not a missing sensor or a sensor failure.
|
||||
- Provide compact status: stopped/cranking/running, synchronized, fueling strategy,
|
||||
open/closed-loop mixture, active cuts and fallback reason. Retain ring/filled
|
||||
circle direction shapes and red Boolean/green Number colors throughout.
|
||||
|
||||
### Migration and acceptance
|
||||
|
||||
Legacy imports initially retain Legacy ideal ECU behavior. Offer a deliberate
|
||||
**Convert to sensor ECU** workflow that creates/reuses named shaft sensors and
|
||||
selected TPS/MAP/IAT attachments, preserves geometry, and reports calibration that
|
||||
cannot be converted. Do not promise an existing ideal-air-mass fuel calibration
|
||||
will produce equivalent behavior from a newly invented VE or Alpha-N table.
|
||||
|
||||
Demonstrations: minimal crank-driven ignition with carburetor; Alpha-N with crank
|
||||
and TPS; speed-density with crank, MAP and IAT; optional lambda correction using
|
||||
the declared ideal tracer; independent ECUs for two shafts. Add sequential and
|
||||
MAF examples only after their prerequisites are complete.
|
||||
|
||||
Acceptance includes required/optional input matrices, sensor positioning after
|
||||
mesh/geometry changes, assembly remapping and host deletion, zero-RPM startup,
|
||||
phase wrap and event crossings, cam loss, wrong-shaft rejection, stale input and
|
||||
fault recovery, deterministic pause/step, explicit fuel-mass accounting, actuator
|
||||
ownership and optional-feature degradation. Compare controller estimates against
|
||||
physical state diagnostically without feeding that ground truth back secretly.
|
||||
|
||||
Planning references:
|
||||
|
||||
- [Speeduino overview: supported strategies and trigger inputs](https://wiki.speeduino.com/en/Overview)
|
||||
- [Speeduino tuning: MAP and air-density/load estimation](https://wiki.speeduino.com/en/tuning/Tuning)
|
||||
- [MegaSquirt MS3 hardware guide: crank/cam triggering examples](https://www.msextra.com/doc/pdf/MS3XV30_Hardware-1.4.pdf)
|
||||
258
docs/WORKSHOP_UI_PLAN.md
Normal file
258
docs/WORKSHOP_UI_PLAN.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Workshop UI rework
|
||||
|
||||
Design specification, updated 2026-11-01. Implementation status is listed below.
|
||||
Physical parts, typed signals and ECU requirements are defined in the
|
||||
[machine systems roadmap](MECHANICAL_BUILDER_PLAN.md).
|
||||
|
||||
## Implementation status
|
||||
|
||||
The first UI pass implements the dark theme, shared adaptive panel geometry,
|
||||
resizing/collapse with saved preferences, library search/categories and previews,
|
||||
an Objects list, a measured inspector with stacked property fields, fitted control
|
||||
labels and hover disclosure, direct click/drag connections, persistent Run/Pause/
|
||||
Step controls, navigable checks, and a separate resizable oscilloscope dock.
|
||||
Existing ECU assignment ports use input rings and filled output circles; gas
|
||||
ports retain their physical semantics. The Sense inspector now adds physical
|
||||
sensor taps, unresolved binding repair and per-sample live outputs, using green
|
||||
Number and red Boolean output dots. Sensor attachments remain locked during Test.
|
||||
|
||||
The builder and desktop UI regressions pass. Rendered review covers empty, ECU,
|
||||
Checks, Meters, running/paused and collapsed layouts at 1920x1080 and 1280x720.
|
||||
|
||||
Logic/split views now share the running machine, with a searchable node library,
|
||||
typed click/drag wiring, reference selection, live values/controls, parameter
|
||||
inspectors and compact number trends. Input rings/output circles and red Boolean/
|
||||
green Number colors are checked in rendered output. Thirteen focused suites pass;
|
||||
graph and split layouts are reviewed at 1920x1080 and 1280x720.
|
||||
|
||||
Grouped ECU connectors follow their runtime systems. Automatic DPI/UI scale
|
||||
overrides, an object hierarchy with
|
||||
visibility groups, richer property grouping/help and pinned operator controls
|
||||
remain follow-up UI work. Their design below remains the intended direction.
|
||||
|
||||
## Confirmed direction
|
||||
|
||||
- Dark engineering editor with compact toolbars, clear panels and restrained
|
||||
colors. Reserve strong colors for signal types, selection and relevant state.
|
||||
- Separate **3D** and **Logic** tabs, with an optional split view. Selection and
|
||||
running simulation are shared across views; camera and canvas navigation state
|
||||
remain independent. Switching views does not restart Test.
|
||||
- Connect by dragging between ports or clicking a port then its target. A
|
||||
dedicated Connect mode is not required. Escape cancels a pending connection.
|
||||
- Signal inputs are hollow rings; outputs are filled circles. Boolean is red,
|
||||
Number green. Bidirectional physical ports use neutral domain symbols.
|
||||
- Panels remain in predictable positions, resize and collapse, and remember
|
||||
their widths. Provide a reset-layout action and constrain restored sizes to
|
||||
the current window so the central workspace stays usable.
|
||||
- The library uses compact rows with small previews, categories and search.
|
||||
Selecting an item shows a larger preview with its purpose and essential ports.
|
||||
- Starting Test preserves the current workspace, selection and navigation state.
|
||||
Lock structural edits while allowing safe live tuning and logic inspection.
|
||||
- Test starts running. Pause and Step remain visible; Step is enabled when
|
||||
paused, with a tooltip explaining that state when disabled.
|
||||
- Primary design target is a 1920x1080 desktop window. The first priority is
|
||||
consistent appearance and layout, especially preventing text/control overlap.
|
||||
- Remaining routine design decisions are made autonomously, per user direction.
|
||||
|
||||
## Layout and visual baseline
|
||||
|
||||
Use a shared layout/component layer rather than individually positioned controls.
|
||||
Rendering and hit testing consume the same computed rectangles. Define shared
|
||||
theme values for spacing, typography, surfaces, borders and interaction states.
|
||||
|
||||
Initial dimensions below are logical pixels at 100% UI scale, and may be tuned
|
||||
through visual review while preserving the agreed structure.
|
||||
|
||||
| Element | Default |
|
||||
| --- | --- |
|
||||
| Spacing | 4 px base unit; 8 px control gaps; 12 px panel padding; 16 px section gaps |
|
||||
| Typography | 14 px body/fields, 12 px secondary labels, 16 px section titles; measured line height |
|
||||
| Controls | 30 px minimum row height, increased when text or UI scale requires it |
|
||||
| Top command bar | 44 px, with less-used file actions in a menu |
|
||||
| Workspace toolbar | 36 px; 3D/Logic tabs and split toggle stay discoverable |
|
||||
| Left panel | 260 px default; resizable between 220 and 420 px |
|
||||
| Inspector | 340 px default; resizable between 280 and 520 px |
|
||||
| Bottom area | Collapsed initially; 240 px default when opened, vertically resizable |
|
||||
| Status bar | 24 px minimum; longer messages go to Checks or a tooltip |
|
||||
| UI scale | Auto from display scaling, plus 100%, 125%, 150% and 200% overrides |
|
||||
|
||||
Use a neutral charcoal palette with clear panel boundaries and one restrained
|
||||
selection accent. Reuse the same fields, buttons, tabs, badges and menus across
|
||||
all views. Strong red/green remains reserved for the agreed signal types; add
|
||||
icons/text to distinguish faults and success from those types.
|
||||
|
||||
Support a 1280x720 window at 100% scale through a compact layout. As available
|
||||
logical space shrinks, move secondary toolbar actions into an overflow menu,
|
||||
collapse the library/object panel, and offer the inspector as a temporary panel.
|
||||
Preserve access to required actions and readable text instead of shrinking fonts.
|
||||
If split view cannot fit two usable canvases, use a single tab and explain why
|
||||
split is unavailable. Large UI scaling follows the same compact behavior.
|
||||
|
||||
Choose a vertical split by default: 3D left, Logic right, with a draggable divider
|
||||
and a swap action. Remember divider position, panel widths/collapse states and
|
||||
active view in user preferences; clamp them on restore. Store machine-specific
|
||||
camera/canvas layout separately from physical geometry and signal values.
|
||||
|
||||
### Text and overlap rules
|
||||
|
||||
- Measure text before layout. Use content-sized rows and explicit minimum sizes;
|
||||
never assume every property label fits a fixed width.
|
||||
- Inspector rows place label, value, unit and optional action in separate layout
|
||||
cells. Narrow inspectors stack labels above fields; units never cover digits.
|
||||
- Wrap help and diagnostic text and grow the row. Ellipsize long names only in
|
||||
compact lists/tabs, with the complete name available on hover and in selection.
|
||||
- Clip rendering and pointer handling to the same panel scroll region. Fixed
|
||||
headers and footers reserve their own space so scrolling content cannot cover
|
||||
buttons, tabs or trace controls.
|
||||
- Tooltips and menus reposition within the window and respect their anchor.
|
||||
Keep the focused text field visible when scrolling or opening a panel.
|
||||
- Lay out ECU ports from descriptor rows with adequate label/value space; nodes
|
||||
grow vertically as ports expand. Wires route away from pin labels before bends.
|
||||
- World labels use screen-space placement with collision suppression; prioritize
|
||||
selected/hovered labels and expose the rest on demand. Hidden labels must not
|
||||
hide the underlying port's tooltip or named connection-list access.
|
||||
|
||||
## Workspace structure
|
||||
|
||||
Proposed layout: a slim document/command bar, workspace tabs, a left library/object
|
||||
panel, central workspace, right inspector, and optional bottom diagnostics/traces.
|
||||
Use resize handles and collapse controls within this predictable layout.
|
||||
|
||||
- Top bar: machine name, unsaved status, file actions, undo/redo and simulation
|
||||
controls. Show current run state unambiguously even in Logic view.
|
||||
- 3D toolbar: Select, Move, Rotate, Route, snapping and fit/focus. Connection
|
||||
gestures work directly on visible ports and do not start a transform drag.
|
||||
- Logic toolbar: node search/add, frame selection/all, connection filters and
|
||||
live-value visibility. Node creation targets the canvas under the pointer.
|
||||
- Library: parts, sensors, logic nodes and saved assemblies with search and
|
||||
categories, displayed as compact rows with small previews. Show a larger
|
||||
selected-item preview and compatibility when adding from a signal port.
|
||||
- Objects: named parts, sensors, shafts, ECUs and assemblies. Search, select,
|
||||
focus, rename and hide without removing simulation membership.
|
||||
- Inspector: selection identity, grouped properties, connections and contextual
|
||||
help. Show units next to editable values and explain locked Build properties.
|
||||
- Bottom area: navigable checks, traces and event history. Selecting an issue
|
||||
locates the relevant part, port or connection in the appropriate view.
|
||||
- Status bar: current gesture, keyboard hints and short feedback. Important
|
||||
failures remain in Checks rather than disappearing with a transient message.
|
||||
|
||||
## Core workflows
|
||||
|
||||
### Place and configure
|
||||
|
||||
Search for a part, select it, preview placement, then click to place. Shift repeats
|
||||
placement. Escape cancels. Placed parts immediately expose a name and the small
|
||||
set of essential properties; advanced groups expand on demand. Preserve current
|
||||
copy/paste, assembly and undo workflows as the UI changes.
|
||||
|
||||
For multiple selections show selection count, pivot and mixed values. Edits apply
|
||||
only to compatible properties; do not display one arbitrary part's value as a
|
||||
shared value. Selection/focus is synchronized with the object tree and Logic view.
|
||||
|
||||
### Connect
|
||||
|
||||
Hover highlights a port with name, domain, direction, units and connection state.
|
||||
Dragging from it or clicking it begins a preview. Compatible targets stand out;
|
||||
incompatible targets explain the reason. A separate checkmark/cross communicates
|
||||
validity without overwriting the red/green signal colors.
|
||||
|
||||
Keep ring centers easy to pick and port target size readable at any zoom. For
|
||||
overlapping ports provide a named candidate chooser. Clicking a part body selects
|
||||
it; hovering a hidden port must not silently steal a move gesture.
|
||||
|
||||
Signal connections do not move physical parts. Gas connections keep their current
|
||||
geometry/snapping rules and show the proposed pipe path before commit. Physical
|
||||
port alignment errors are visible. Disconnect/reconnect commands are available
|
||||
from ports and the inspector; all connection edits are undoable.
|
||||
|
||||
### Add sensors and configure ECUs
|
||||
|
||||
Use Add sensor on a selected part or a specific pipe location. Offer supported
|
||||
sensor types, show the tap/pickup marker, and create a matching Logic reference.
|
||||
Selecting either representation reveals its host and measurement location.
|
||||
|
||||
ECU inspector groups Required, Connected and Optional inputs by function. Keep
|
||||
connected ports visible and let optional groups expand. A guided checklist helps
|
||||
choose a strategy, attach required sensors and assign actuator targets. Every
|
||||
measurement displays units, source, validity and fallback state.
|
||||
|
||||
### Inspect and test
|
||||
|
||||
Simulation controls are independent of 3D/Logic tab selection. Distinguish Run,
|
||||
Pause, Step and Stop/return-to-edit. Show simulation speed and time. Structural
|
||||
properties explain why editing is locked during Test; safe live tuning shows
|
||||
its applied value without resetting the machine.
|
||||
|
||||
Entering Test does not switch to a different workspace or change selection.
|
||||
Keep the current 3D camera, Logic canvas and split-view arrangement. Stopping
|
||||
returns to editing in that same context; live tuning retains existing persistence
|
||||
and undo behavior. Start running immediately, with Pause and Step available in
|
||||
the persistent simulation controls.
|
||||
|
||||
Hovering a signal reveals its current value; the inspector can pin it to a trace
|
||||
or control panel. Physical measurements can also be added from the selected part.
|
||||
Readouts identify the shaft, sensor or port they measure. Missing and invalid
|
||||
signals never display as an ordinary zero or false state.
|
||||
|
||||
## Readability and interaction requirements
|
||||
|
||||
- Text and controls respond to window size and UI scaling. Scroll panels rather
|
||||
than drawing controls outside their bounds; avoid the current fixed-coordinate
|
||||
overlap risk as parts and properties grow.
|
||||
- Focused numeric fields accept keyboard edits, units are visible, and invalid
|
||||
input remains editable with a local explanation. Escape cancels the edit;
|
||||
shortcuts do not trigger transforms while typing in a field.
|
||||
- Preserve useful shortcuts, add visible tooltips, and keep camera gestures
|
||||
separate from node-canvas gestures. Document both in a compact help overlay.
|
||||
- Selection, warning and disabled states remain legible in the dark theme.
|
||||
Signal meaning is also conveyed through labels and direction shapes.
|
||||
- Keep the viewport stable when changing selection. Fit/focus is explicit.
|
||||
Unrelated panel refreshes must not jump scroll position or steal text focus.
|
||||
- Keep raw rendering and UI state ownership explicit so panel rendering remains
|
||||
reliable after 3D drawing. Rendering optimization must preserve hit testing.
|
||||
|
||||
## Delivery slices
|
||||
|
||||
1. Appearance and layout foundation: shared controls/theme, responsive panels,
|
||||
typography, text measurement, clipping and consistent input handling. Rework
|
||||
the existing workshop first so today's parts already have a coherent UI.
|
||||
2. Descriptor-driven inspector, connection gestures, named ports and filters.
|
||||
3. Searchable library/object tree, then Logic tab and optional split view with
|
||||
shared selection, stable node layout and live values.
|
||||
4. Sensor placement and grouped ECU connector workflow.
|
||||
5. Integrated test controls, traces, checks and pinned operator controls.
|
||||
|
||||
Coordinate these slices with the systems roadmap: UI can display a type only
|
||||
after document/runtime descriptors define it. Validate layouts at the agreed
|
||||
minimum window size and scaling, connection picking in dense assemblies, keyboard
|
||||
focus, save/restore behavior and the same workflows across 3D, Logic and split view.
|
||||
|
||||
## Navigation decisions
|
||||
|
||||
Keep the existing familiar 3D controls: Alt+left drag or right drag on empty space
|
||||
orbits, middle drag pans, wheel zooms, F focuses selection and Home fits all.
|
||||
An actual drag orbits; right click without dragging opens the context menu.
|
||||
UI interaction consumes input before viewport handling, and text editing captures
|
||||
its shortcuts. Escape cancels the active gesture before clearing selection.
|
||||
|
||||
Logic uses wheel zoom about the pointer and middle drag to pan. Plain left click
|
||||
selects, Shift modifies selection, and dragging empty canvas makes a selection
|
||||
rectangle. Drag nodes by their header, ports by their connector and panel sizes
|
||||
by their divider; the pointer/hover state identifies the pending action.
|
||||
|
||||
## First milestone acceptance
|
||||
|
||||
Review the existing single-cylinder, flat-six and V8 imports with long names,
|
||||
multi-selection, pipe route editing, ECU properties, Checks and instruments.
|
||||
Exercise empty, selected, hovered, disabled, focused, invalid and running states.
|
||||
|
||||
At 1920x1080 and 1280x720 at 100% scale, and at representative larger display
|
||||
scales, verify that text never overlaps unrelated controls, field units remain
|
||||
readable, panels scroll within bounds and essential commands stay reachable.
|
||||
Resize while editing, reopen saved panel layouts, expand long property groups
|
||||
and open menus near window edges. Appearance/layout review is required before
|
||||
adding the new systems' more complex panels. Later slices repeat this review for
|
||||
the dense ECU connector view, Logic and split view as they become available.
|
||||
|
||||
No further preference decisions are blocking this plan. Implementation will use
|
||||
these defaults and adjust details through focused visual and interaction review.
|
||||
@@ -29,7 +29,7 @@ struct CarburetorConfig {
|
||||
|
||||
class Carburetor : public CarburetorConfig {
|
||||
public:
|
||||
explicit Carburetor(float throat_diameter = 0.0381f);
|
||||
explicit Carburetor(float throat_diameter = 0.0381f,const CarburetorConfig& config={});
|
||||
|
||||
// air_mass_flow is positive from atmosphere toward the engine.
|
||||
void step(float dt, float pedal, float slide, float rpm, float air_mass_flow,
|
||||
|
||||
@@ -13,6 +13,7 @@ struct Port {
|
||||
float gamma_value{1.4f}, gas_constant_value{287.0f};
|
||||
float air_fraction{1.0f};
|
||||
float fuel_fraction{};
|
||||
float fuel_equivalent_fraction{}; // fuel origin, including fuel already burned
|
||||
float* owner_energy{};
|
||||
float* owner_air_mass{};
|
||||
float* owner_exhaust_mass{};
|
||||
@@ -25,6 +26,8 @@ class Component {
|
||||
public:
|
||||
virtual ~Component() = default;
|
||||
virtual std::vector<Port*> ports() = 0;
|
||||
// Optional actuation at each CFL substep, before boundary fluxes are resolved.
|
||||
virtual void prepare_step(float /*dt*/) {}
|
||||
virtual void update_state(float dt) = 0;
|
||||
};
|
||||
|
||||
|
||||
45
include/FluidSim/Desktop/LogicEditor.hpp
Normal file
45
include/FluidSim/Desktop/LogicEditor.hpp
Normal file
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/Desktop/WorkshopLayout.hpp"
|
||||
#include <SFML/Graphics.hpp>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
namespace fluidsim::port::desktop {
|
||||
struct LogicEditorHooks {
|
||||
MachineDocument& document;
|
||||
const MachineSnapshot* live{};
|
||||
bool shift{};
|
||||
std::function<bool(const std::function<void()>&)> edit;
|
||||
std::function<void(std::uint32_t,bool)> boolean_control;
|
||||
std::function<void(std::uint32_t,double)> number_control;
|
||||
std::function<void()> release_buttons;
|
||||
std::function<void(std::uint32_t)> select_physical,focus_physical;
|
||||
std::function<void(const std::string&)> status;
|
||||
std::function<void()> example;
|
||||
};
|
||||
class LogicEditor {
|
||||
public:
|
||||
LogicEditor();
|
||||
~LogicEditor();
|
||||
void reset_document();
|
||||
void deactivate(const LogicEditorHooks&);
|
||||
bool editing_text()const;
|
||||
bool active_gesture()const;
|
||||
std::uint32_t selected_node()const;
|
||||
void select_node(std::uint32_t,const MachineDocument&);
|
||||
void select_part_reference(std::uint32_t,const MachineDocument&);
|
||||
void fit(const LogicGraph&,sf::FloatRect,bool selection_only=false);
|
||||
void reveal(std::uint32_t,const MachineDocument&,sf::FloatRect);
|
||||
sf::Vector2f project(LogicPosition,sf::FloatRect)const;
|
||||
LogicPosition unproject(sf::Vector2f,sf::FloatRect)const;
|
||||
sf::FloatRect node_rect(const LogicNode&,sf::FloatRect)const;
|
||||
sf::Vector2f pin_position(const LogicNode&,const std::string&,sf::FloatRect)const;
|
||||
// Panels can be owned independently in split view; the canvas is always live.
|
||||
bool event(const sf::Event&,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect canvas,bool panels);
|
||||
void draw(sf::RenderWindow&,const sf::Font*,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect canvas,bool panels);
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
57
include/FluidSim/Desktop/MachineBuilder.hpp
Normal file
57
include/FluidSim/Desktop/MachineBuilder.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/Desktop/OrbitCamera.hpp"
|
||||
#include <SFML/Graphics.hpp>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
namespace fluidsim::port::desktop {
|
||||
struct BuilderRay {Vec3 origin,direction;};
|
||||
BuilderRay builder_ray(const OrbitCamera&,sf::FloatRect,sf::Vector2f);
|
||||
std::optional<sf::Vector2f> builder_project(const OrbitCamera&,sf::FloatRect,Vec3);
|
||||
// Returns metres along the ray; OBBs and duct centerlines use the rendered dimensions.
|
||||
std::optional<float> builder_pick_part(const MachinePart&,BuilderRay,float tolerance=0);
|
||||
Vec3 builder_rotate_world(Vec3 euler,unsigned axis,float degrees);
|
||||
struct BuilderPipeLocation {std::size_t segment{};Vec3 local_point;float screen_distance{};};
|
||||
std::optional<BuilderPipeLocation> builder_pipe_location(const OrbitCamera&,sf::FloatRect,const MachinePart&,sf::Vector2f);
|
||||
struct MachinePlaybackStats {double simulation_seconds{};std::uint64_t underruns{};std::size_t buffered{};double speed{1};bool paused{};};
|
||||
enum class WorkshopView {ThreeD,Logic,Split};
|
||||
class MachineBuilder {
|
||||
public:
|
||||
MachineBuilder();
|
||||
~MachineBuilder();
|
||||
const MachineDocument& document()const;
|
||||
std::vector<std::uint32_t> selected_parts()const;
|
||||
void set_document(MachineDocument,std::filesystem::path source={});
|
||||
bool load_file(const std::filesystem::path&);
|
||||
void start_test();
|
||||
void stop_test();
|
||||
bool testing()const;
|
||||
MachinePlaybackStats playback_stats()const;
|
||||
void set_test_speed(double);
|
||||
void pause_test();
|
||||
void step_test();
|
||||
std::vector<MachineMeasurement> measurements()const;
|
||||
bool export_measurements(const std::filesystem::path&);
|
||||
bool finished()const;
|
||||
bool wants_close_window()const;
|
||||
bool handle_event(const sf::Event&,sf::Vector2u);
|
||||
void update();
|
||||
void draw(sf::RenderWindow&,const sf::Font*);
|
||||
static sf::FloatRect viewport(sf::Vector2u);
|
||||
sf::FloatRect workspace_viewport(sf::Vector2u)const;
|
||||
sf::FloatRect logic_viewport(sf::Vector2u)const;
|
||||
WorkshopView workspace_view()const;
|
||||
void set_workspace_view(WorkshopView);
|
||||
std::uint32_t selected_logic_node()const;
|
||||
std::vector<LogicNodeState> logic_values()const;
|
||||
sf::FloatRect logic_node_rectangle(std::uint32_t,sf::Vector2u)const;
|
||||
sf::Vector2f logic_pin_position(std::uint32_t,const std::string&,sf::Vector2u)const;
|
||||
void restore_workspace_preferences();
|
||||
const OrbitCamera& camera()const;
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
void run_machine_builder(sf::RenderWindow&,const sf::Font*,const std::filesystem::path& file={},bool play=false);
|
||||
}
|
||||
21
include/FluidSim/Desktop/MachineRenderer3D.hpp
Normal file
21
include/FluidSim/Desktop/MachineRenderer3D.hpp
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/Desktop/OrbitCamera.hpp"
|
||||
#include <SFML/Graphics.hpp>
|
||||
#include <memory>
|
||||
namespace fluidsim::port::desktop {
|
||||
struct MachineRenderStats {std::uint64_t pipe_mesh_builds{},icon_atlas_builds{};std::size_t cached_pipes{};};
|
||||
class MachineRenderer3D {
|
||||
public:
|
||||
MachineRenderer3D();
|
||||
~MachineRenderer3D();
|
||||
MachineRenderer3D(const MachineRenderer3D&)=delete;
|
||||
MachineRenderer3D& operator=(const MachineRenderer3D&)=delete;
|
||||
void draw(sf::RenderWindow&,const MachineDocument&,const MachineSnapshot*,sf::FloatRect,const OrbitCamera&,std::uint32_t selected,const MachinePart* ghost=nullptr,const std::vector<std::uint32_t>& selection={});
|
||||
void draw_icon(sf::RenderWindow&,MachinePartKind,sf::FloatRect);
|
||||
MachineRenderStats stats()const;
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
10
include/FluidSim/Desktop/PortAppearance.hpp
Normal file
10
include/FluidSim/Desktop/PortAppearance.hpp
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include "FluidSim/MachinePorts.hpp"
|
||||
#include <SFML/Graphics/Color.hpp>
|
||||
namespace fluidsim::port::desktop {
|
||||
inline sf::Color port_color(PortDomain domain){
|
||||
switch(domain){case PortDomain::Boolean:return {235,92,102};case PortDomain::Number:return {87,201,128};case PortDomain::Gas:return {189,200,214};case PortDomain::Rotational:return {229,184,91};case PortDomain::Translational:return {177,139,220};case PortDomain::EcuAssignment:return {91,161,242};}
|
||||
return sf::Color::White;
|
||||
}
|
||||
inline bool filled_port(const MachinePortDescriptor& descriptor){return descriptor.direction==PortDirection::Output;}
|
||||
}
|
||||
@@ -2,6 +2,26 @@
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <SFML/Graphics.hpp>
|
||||
#include <optional>
|
||||
#include <memory>
|
||||
namespace fluidsim::port::desktop {
|
||||
std::optional<std::size_t> choose_scenario(sf::RenderWindow&,const sf::Font*,const std::vector<ScenarioExample>&);
|
||||
struct ScenarioMenuChoice {
|
||||
std::optional<std::size_t> example;
|
||||
std::filesystem::path machine;
|
||||
bool play{};
|
||||
};
|
||||
enum class MenuScreen {Home,Workshop,Scenarios};
|
||||
class ScenarioMenu {
|
||||
public:
|
||||
ScenarioMenu(std::vector<ScenarioExample>,std::vector<std::filesystem::path> machines={});
|
||||
~ScenarioMenu();
|
||||
bool handle_event(const sf::Event&,sf::Vector2u);
|
||||
void draw(sf::RenderWindow&,const sf::Font*);
|
||||
bool finished()const;
|
||||
MenuScreen screen()const;
|
||||
std::optional<ScenarioMenuChoice> take_choice();
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
std::optional<ScenarioMenuChoice> choose_scenario(sf::RenderWindow&,const sf::Font*,const std::vector<ScenarioExample>&);
|
||||
}
|
||||
|
||||
8
include/FluidSim/Desktop/WindowPresentation.hpp
Normal file
8
include/FluidSim/Desktop/WindowPresentation.hpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <SFML/Graphics/RenderWindow.hpp>
|
||||
|
||||
namespace fluidsim::port::desktop {
|
||||
// Maximize the decorated OS window; retain normal restore/minimize controls.
|
||||
void maximize_window(sf::RenderWindow&);
|
||||
bool window_is_maximized(const sf::RenderWindow&);
|
||||
}
|
||||
30
include/FluidSim/Desktop/WorkshopLayout.hpp
Normal file
30
include/FluidSim/Desktop/WorkshopLayout.hpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#include <SFML/Graphics/Rect.hpp>
|
||||
#include <SFML/System/Vector2.hpp>
|
||||
#include <algorithm>
|
||||
|
||||
namespace fluidsim::port::desktop {
|
||||
// One geometry source for drawing, picking and viewport projection.
|
||||
struct WorkshopLayout {
|
||||
sf::FloatRect left, right, viewport, library, search;
|
||||
float inspector_x{}, inspector_width{}, height{}, width{};
|
||||
static constexpr float field_top=302, field_height=52;
|
||||
int field_rows()const{return std::max(1,int((height-164-field_top)/field_height));}
|
||||
int library_rows()const{return std::max(1,int(library.size.y/36));}
|
||||
sf::FloatRect field_row(int row)const{return {{inspector_x+12,field_top+row*field_height},{inspector_width-24,48}};}
|
||||
sf::FloatRect field_value(int row)const{auto r=field_row(row);return {{r.position.x,r.position.y+21},{r.size.x-68,26}};}
|
||||
static WorkshopLayout calculate(sf::Vector2u size,float left_width=260,float right_width=340,bool left_closed=false,bool right_closed=false){
|
||||
WorkshopLayout l;l.width=float(size.x);l.height=float(size.y);
|
||||
const float available=std::max(480.f,l.width-440);
|
||||
float lw=left_closed?28.f:std::clamp(left_width,220.f,420.f),rw=right_closed?28.f:std::clamp(right_width,280.f,520.f);
|
||||
if(lw+rw>available){const float excess=lw+rw-available;rw=std::max(right_closed?28.f:280.f,rw-excess);lw=std::max(left_closed?28.f:180.f,std::min(lw,available-rw));}
|
||||
l.left={{8,96},{lw,std::max(100.f,l.height-154)}};
|
||||
l.right={{l.width-rw-8,96},{rw,std::max(100.f,l.height-154)}};
|
||||
l.inspector_x=l.right.position.x;l.inspector_width=rw;
|
||||
l.viewport={{l.left.position.x+lw+8,96},{std::max(100.f,l.right.position.x-lw-24),std::max(100.f,l.height-154)}};
|
||||
l.search={{20,152},{lw-24,30}};
|
||||
l.library={{20,230},{lw-24,std::max(36.f,l.height-474)}};
|
||||
return l;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,29 @@
|
||||
#pragma once
|
||||
#include "FluidSim/MachinePorts.hpp"
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
// Accepts only coherent, current channels from one physical crank pickup.
|
||||
// Signed reverse measurements remain valid sensors, but cannot drive this
|
||||
// forward-only ignition controller. A stationary crank is valid.
|
||||
std::optional<SignalTiming> ecu_crank_sample(const SignalValue& speed,
|
||||
const SignalValue& angle,double time);
|
||||
|
||||
class CrankIgnitionClock {
|
||||
public:
|
||||
struct Result {bool valid{};unsigned sparks{};};
|
||||
// Crank-only wasted spark: one event per revolution at the configured
|
||||
// pickup angle, without consulting a cylinder's hidden 720-degree phase.
|
||||
Result sample(const SignalValue& speed,const SignalValue& angle,
|
||||
double time,double advance_degrees,double tdc_degrees=0);
|
||||
void reset(){previous_.reset();last_event_cycle_.reset();}
|
||||
private:
|
||||
std::optional<SignalTiming> previous_;
|
||||
std::optional<double> last_event_cycle_;
|
||||
};
|
||||
|
||||
// Ideal sensors and injectors; calibration parameters are not engine measurements.
|
||||
struct EcuConfig {
|
||||
enum class IdleControl { Feedback, FixedOpening };
|
||||
@@ -18,6 +39,8 @@ struct EcuConfig {
|
||||
float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f};
|
||||
bool rev_limit_enabled{true};
|
||||
bool rev_limit_fuel_cut_enabled{true};
|
||||
bool rev_limit_ignition_cut_enabled{true};
|
||||
float rev_limit_cut_time{}; // Minimum cut duration in seconds, followed by RPM hysteresis.
|
||||
float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f};
|
||||
bool overrun_fuel_cut_enabled{true};
|
||||
bool overrun_ignition_cut_enabled{false};
|
||||
@@ -35,7 +58,7 @@ public:
|
||||
float target_afr() const { return afr_; }
|
||||
float spark_advance() const { return spark_; }
|
||||
bool fuel_cut() const { return (limiter_ && rev_limit_fuel_cut_enabled) || (overrun_ && overrun_fuel_cut_enabled) || fault_; }
|
||||
bool ignition_cut() const { return limiter_ || (overrun_ && overrun_ignition_cut_enabled) || fault_; }
|
||||
bool ignition_cut() const { return (limiter_ && rev_limit_ignition_cut_enabled) || (overrun_ && overrun_ignition_cut_enabled) || fault_; }
|
||||
bool rev_limited() const { return limiter_; }
|
||||
bool idle_active() const { return idle_; }
|
||||
bool sensor_fault() const { return fault_; }
|
||||
@@ -45,5 +68,51 @@ private:
|
||||
bool initialized_{};
|
||||
float opening_{}, target_{}, integral_{}, afr_{14.7f}, spark_{10.0f};
|
||||
bool limiter_{}, overrun_{}, fault_{}, idle_{};
|
||||
float limiter_time_{};
|
||||
};
|
||||
|
||||
// Separate from the legacy ECU: measurements are supplied through typed wires.
|
||||
// Alpha-N cells are calibrated kg per cylinder per four-stroke cycle, indexed
|
||||
// by RPM rows and measured TPS columns. No trapped-air-mass input is available.
|
||||
struct SensorEcuConfig {
|
||||
enum class Strategy {Ignition,AlphaN,SpeedDensity};
|
||||
Strategy strategy{Strategy::Ignition};
|
||||
std::vector<EcuConfig::IgnitionPoint> ignition_map{{0,10},{6000,30}};
|
||||
std::vector<double> fuel_rpm{0,6000},fuel_tps{0,1};
|
||||
std::vector<double> fuel_per_cycle{0.000002,0.00002,0.000002,0.00002};
|
||||
// Open-loop dry ideal-gas estimate per cylinder. Geometry, VE and fuel AFR
|
||||
// are calibration; MAP and IAT must arrive as current wired measurements.
|
||||
double displacement{.0003},air_gas_constant{287},target_afr{14.7};
|
||||
std::vector<double> ve_rpm{0,6000},ve_pressure{20000,100000};
|
||||
std::vector<double> volumetric_efficiency{.9,.9,.9,.9};
|
||||
bool rev_limit_enabled{true};
|
||||
double rev_limit_rpm{7000},rev_resume_rpm{6800};
|
||||
};
|
||||
inline constexpr int sensor_ecu_strategy_count=3;
|
||||
const char* sensor_ecu_strategy_key(SensorEcuConfig::Strategy);
|
||||
const char* sensor_ecu_strategy_name(SensorEcuConfig::Strategy);
|
||||
SensorEcuConfig::Strategy sensor_ecu_strategy_from_key(std::string_view);
|
||||
SensorEcuConfig::Strategy next_sensor_ecu_strategy(SensorEcuConfig::Strategy);
|
||||
struct SensorEcuMeasurements {SignalValue speed,angle,tps,map,iat;};
|
||||
struct SensorEcuState {
|
||||
bool sensor_fault{true},rev_limited{};
|
||||
double spark_advance{},fuel_per_cycle{},fuel_mass_flow{};
|
||||
double estimated_air_per_cycle{},volumetric_efficiency{};
|
||||
unsigned spark_events{};
|
||||
};
|
||||
class SensorEcu {
|
||||
public:
|
||||
explicit SensorEcu(SensorEcuConfig config);
|
||||
void apply_calibration(SensorEcuConfig config);
|
||||
const SensorEcuState& sample(const SignalValue& speed,const SignalValue& angle,
|
||||
const SignalValue& tps,double time);
|
||||
const SensorEcuState& sample(const SensorEcuMeasurements&,double time);
|
||||
const SensorEcuState& state()const{return state_;}
|
||||
void reset(){clock_.reset();state_={};limiter_=false;}
|
||||
private:
|
||||
SensorEcuConfig config_;
|
||||
CrankIgnitionClock clock_;
|
||||
SensorEcuState state_;
|
||||
bool limiter_{};
|
||||
};
|
||||
} // namespace fluidsim::port
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "FluidSim/Constants.hpp"
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
@@ -25,6 +27,8 @@ public:
|
||||
float average_power{}, average_torque{};
|
||||
|
||||
void add_torque(float torque);
|
||||
float take_torque(); // Consume gas torque when an external shaft integrator owns motion.
|
||||
void record_output(float torque,float power);
|
||||
void step(float dt);
|
||||
private:
|
||||
float external_torque_{};
|
||||
@@ -51,6 +55,7 @@ public:
|
||||
float rev_limit_rpm{7500.0f}, rev_limit_cut_time{0.05f}, rev_limit_hysteresis{200.0f};
|
||||
float air_mass{}, exhaust_mass{}, energy{}, volume{}, pressure{101325.0f};
|
||||
float fuel_mass{}, burn_fraction{};
|
||||
float fuel_equivalent_mass{}; // passive fuel-origin tracer, unaffected by combustion
|
||||
bool combustion_active{}, fuel_injected{}, rev_limit_active{};
|
||||
Port intake{}, exhaust{};
|
||||
|
||||
@@ -84,6 +89,14 @@ protected:
|
||||
|
||||
class FourStrokeCylinder final : public EngineCylinder {
|
||||
public:
|
||||
struct IgnitionCommand {bool fire{},cut{true};};
|
||||
// Present selects external timing exclusively. A fire pulse is consumed
|
||||
// once; missing subsequent commands cannot recreate internal timed sparks.
|
||||
// Requires physically transported fuel, including carburetor operation.
|
||||
std::optional<IgnitionCommand> ignition_command;
|
||||
// Once per cycle, before ignition consumes fuel. Independent of spark cut.
|
||||
float charge_afr{std::numeric_limits<float>::quiet_NaN()};
|
||||
bool charge_sampled{};
|
||||
struct CamPoint { float angle_degrees{}, lift_metres{}; };
|
||||
struct ValveDynamics {
|
||||
float mass_kg{0.035f};
|
||||
@@ -106,6 +119,8 @@ public:
|
||||
static float cam_lift_at(const CamProfile& profile,float degrees) { return sample_cam(profile,degrees).lift_metres; }
|
||||
|
||||
private:
|
||||
float external_burn_angle_{};
|
||||
bool external_charge_ready_{};
|
||||
struct ValveState { float lift{},velocity{};bool floating{}; };
|
||||
const CamProfile* intake_cam_{};
|
||||
const CamProfile* exhaust_cam_{};
|
||||
|
||||
@@ -54,10 +54,12 @@ public:
|
||||
float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); }
|
||||
float fuel_fraction() const { return fuel_mass_ / std::max(mass(), 1e-12f); }
|
||||
float fuel_mass() const { return fuel_mass_; }
|
||||
float fuel_equivalent_fraction()const{return fuel_equivalent_mass_/std::max(mass(),1e-12f);}
|
||||
void add_fuel_mass(float value, float temperature = 300.0f) {
|
||||
if (!std::isfinite(value) || value < 0.0f || !std::isfinite(temperature) || temperature <= 0.0f)
|
||||
throw std::invalid_argument("invalid fuel addition");
|
||||
fuel_mass_ += value;
|
||||
fuel_equivalent_mass_ += value;
|
||||
energy_ += value * gas_constant / (gamma - 1.0f) * temperature;
|
||||
sync_ports();
|
||||
}
|
||||
@@ -68,9 +70,11 @@ public:
|
||||
const float total = value * volume_ / (gas_constant * target_t);
|
||||
const float fraction = air_fraction();
|
||||
const float fuel = fuel_fraction();
|
||||
const float equivalent=fuel_equivalent_fraction();
|
||||
air_mass_ = total * fraction;
|
||||
fuel_mass_ = total * fuel;
|
||||
exhaust_mass_ = total * std::max(1.0f - fraction - fuel, 0.0f);
|
||||
fuel_equivalent_mass_=total*equivalent;equivalent_roundoff_=0;
|
||||
energy_ = value * volume_ / (gamma - 1.0f);
|
||||
air_roundoff_ = exhaust_roundoff_ = fuel_roundoff_ = energy_roundoff_ = 0;
|
||||
sync_ports();
|
||||
@@ -80,7 +84,7 @@ public:
|
||||
throw std::invalid_argument("invalid volume timestep");
|
||||
if (dt == 0)
|
||||
return;
|
||||
double d_air = 0, d_exhaust = 0, d_fuel = 0, d_energy = 0;
|
||||
double d_air = 0, d_exhaust = 0, d_fuel = 0, d_energy = 0, d_equivalent=0;
|
||||
const float fraction = air_fraction();
|
||||
const float fuel = fuel_fraction();
|
||||
for (const Port &p : ports_) {
|
||||
@@ -90,11 +94,13 @@ public:
|
||||
d_fuel += double(p.mass_flow) * flow_fuel;
|
||||
d_exhaust += double(p.mass_flow) * std::max(1.0f - flow_fraction - flow_fuel, 0.0f);
|
||||
d_energy += double(p.mass_flow) * p.enthalpy;
|
||||
d_equivalent+=double(p.mass_flow)*(p.mass_flow>=0?std::max(p.fuel_equivalent_fraction,p.fuel_fraction):fuel_equivalent_fraction());
|
||||
}
|
||||
accumulate(air_mass_, air_roundoff_, d_air * dt);
|
||||
accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt);
|
||||
accumulate(fuel_mass_, fuel_roundoff_, d_fuel * dt);
|
||||
accumulate(energy_, energy_roundoff_, d_energy * dt);
|
||||
accumulate(fuel_equivalent_mass_,equivalent_roundoff_,d_equivalent*dt);
|
||||
const float new_volume = volume_ + volume_change_rate * dt;
|
||||
if (!(new_volume > 0) || !std::isfinite(new_volume))
|
||||
throw std::runtime_error("invalid moving volume");
|
||||
@@ -118,6 +124,8 @@ public:
|
||||
air_mass_ = std::max(air_mass_, 0.0f);
|
||||
exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
|
||||
fuel_mass_ = std::max(fuel_mass_, 0.0f);
|
||||
fuel_equivalent_mass_=std::clamp(fuel_equivalent_mass_,fuel_mass_,mass());
|
||||
if(fuel_equivalent_mass_<std::numeric_limits<float>::min()){fuel_equivalent_mass_=0;equivalent_roundoff_=0;}
|
||||
sync_ports();
|
||||
}
|
||||
void update_state(float dt) override { update(dt); }
|
||||
@@ -132,6 +140,7 @@ public:
|
||||
|
||||
private:
|
||||
float volume_, air_mass_{}, exhaust_mass_{}, fuel_mass_{}, energy_;
|
||||
float fuel_equivalent_mass_{};double equivalent_roundoff_{};
|
||||
// Preserve sub-ulp transfers without changing the float port/budget ABI.
|
||||
double air_roundoff_{}, exhaust_roundoff_{}, fuel_roundoff_{}, energy_roundoff_{};
|
||||
static void accumulate(float& value, double& roundoff, double increment) {
|
||||
@@ -149,6 +158,7 @@ private:
|
||||
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
|
||||
p.air_fraction = air_fraction();
|
||||
p.fuel_fraction = fuel_fraction();
|
||||
p.fuel_equivalent_fraction=fuel_equivalent_fraction();
|
||||
}
|
||||
void sync_ports() {
|
||||
for (Port &p : ports_)
|
||||
@@ -161,10 +171,12 @@ struct GasCell {
|
||||
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
|
||||
float air{1.0f};
|
||||
float fuel{};
|
||||
float fuel_equivalent{};
|
||||
};
|
||||
struct GasState {
|
||||
float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f};
|
||||
float fuel{};
|
||||
float fuel_equivalent{};
|
||||
};
|
||||
|
||||
class Pipe1D {
|
||||
@@ -172,7 +184,7 @@ public:
|
||||
std::vector<float> linear_resistance, quadratic_resistance;
|
||||
// Integrated face fluxes: kg/s, N, W, kg/s of fresh air and fuel, positive along +x.
|
||||
struct Flux {
|
||||
float m{}, p{}, e{}, y{}, fuel{};
|
||||
float m{}, p{}, e{}, y{}, fuel{}, fuel_equivalent{};
|
||||
};
|
||||
struct RoeState { double root_density{}, enthalpy{}; };
|
||||
PipeSolver solver{PipeSolver::Rusanov};
|
||||
@@ -200,6 +212,11 @@ public:
|
||||
if (!std::isfinite(cell_areas[i]) || !std::isfinite(cell_lengths[i]) || cell_areas[i] <= 0 ||
|
||||
cell_lengths[i] <= 0)
|
||||
throw std::invalid_argument("pipe geometry must be finite and positive");
|
||||
if(std::any_of(cell_areas.begin(),cell_areas.end(),[&](float a){return a!=cell_areas.front();})){
|
||||
geometry_cfl_lengths_.resize(cells.size());
|
||||
for(std::size_t i=0;i<cells.size();++i){const float a=cell_areas[i],left=i?.5f*(cell_areas[i-1]+a):a,right=i+1<cells.size()?.5f*(a+cell_areas[i+1]):a;
|
||||
geometry_cfl_lengths_[i]=cell_lengths[i]*(a/std::max({a,left,right}));}
|
||||
}
|
||||
}
|
||||
GasState state(int i) const { return primitive(cells.at(static_cast<std::size_t>(i))); }
|
||||
float cell_mass(int i) const { return cells.at(i).rho * cell_areas.at(i) * cell_lengths.at(i); }
|
||||
@@ -212,7 +229,9 @@ public:
|
||||
auto s = primitive(cells[i]);
|
||||
if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure))
|
||||
throw std::runtime_error("nonphysical pipe state in CFL calculation");
|
||||
dt = std::min(dt, cfl * cell_lengths[i] / wave(s));
|
||||
float speed=wave(s),length=cell_lengths[i];
|
||||
if(!geometry_cfl_lengths_.empty()){length=geometry_cfl_lengths_[i];if(i)speed=std::max(speed,wave(primitive(cells[i-1])));if(i+1<cells.size())speed=std::max(speed,wave(primitive(cells[i+1])));}
|
||||
dt = std::min(dt, cfl * length / speed);
|
||||
}
|
||||
return dt;
|
||||
}
|
||||
@@ -242,6 +261,13 @@ public:
|
||||
throw std::runtime_error("nonphysical pipe state in preparation");
|
||||
dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]);
|
||||
}
|
||||
// A narrow cell exchanges flux through faces larger than its own area.
|
||||
// Limit that face/volume ratio and use each face's fastest adjacent wave.
|
||||
// Uniform pipes retain the original fast preparation path.
|
||||
if(!geometry_cfl_lengths_.empty())for(std::size_t i=0;i<cells.size();++i){
|
||||
float speed=waves_[i];if(i)speed=std::max(speed,waves_[i-1]);if(i+1<cells.size())speed=std::max(speed,waves_[i+1]);
|
||||
dt=std::min(dt,cfl*geometry_cfl_lengths_[i]/speed);
|
||||
}
|
||||
if (solver == PipeSolver::Hlle || solver == PipeSolver::Hllc)
|
||||
for (std::size_t i=0; i<cells.size(); ++i) {
|
||||
const auto& s=states_[i];
|
||||
@@ -272,7 +298,7 @@ public:
|
||||
const float u = c.momentum / std::max(c.rho, 1e-12f);
|
||||
const float air = std::clamp(c.air, 0.0f, 1.0f);
|
||||
return {c.rho, u, pipe_gm1 * (c.energy - .5f * c.momentum * u), air,
|
||||
std::clamp(c.fuel, 0.0f, 1.0f - air)};
|
||||
std::clamp(c.fuel, 0.0f, 1.0f - air),std::clamp(std::max(c.fuel,c.fuel_equivalent),0.f,1.f)};
|
||||
}
|
||||
static float wave(const GasState &s) {
|
||||
return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho);
|
||||
@@ -301,7 +327,7 @@ public:
|
||||
const double sr=std::max({ua+ca,ub+cb,uroe+croe});
|
||||
const auto physical=[](const GasState& q,double energy) {
|
||||
const float m=q.rho*q.velocity;
|
||||
return Flux{m,m*q.velocity+q.pressure,float((energy+q.pressure)*q.velocity),m*q.air,m*q.fuel};
|
||||
return Flux{m,m*q.velocity+q.pressure,float((energy+q.pressure)*q.velocity),m*q.air,m*q.fuel,m*q.fuel_equivalent};
|
||||
};
|
||||
const Flux fa=physical(a,ea), fb=physical(b,eb);
|
||||
if(sl>=0) return fa;
|
||||
@@ -312,7 +338,7 @@ public:
|
||||
float((sr*fa.p-sl*fb.p+diff*(b.rho*ub-a.rho*ua))*inv),
|
||||
float((sr*fa.e-sl*fb.e+diff*(eb-ea))*inv),
|
||||
float((sr*fa.y-sl*fb.y+diff*(b.rho*b.air-a.rho*a.air))*inv),
|
||||
float((sr*fa.fuel-sl*fb.fuel+diff*(b.rho*b.fuel-a.rho*a.fuel))*inv)};
|
||||
float((sr*fa.fuel-sl*fb.fuel+diff*(b.rho*b.fuel-a.rho*a.fuel))*inv),float((sr*fa.fuel_equivalent-sl*fb.fuel_equivalent+diff*(b.rho*b.fuel_equivalent-a.rho*a.fuel_equivalent))*inv)};
|
||||
};
|
||||
if constexpr(method==PipeSolver::Hlle) return hlle();
|
||||
const double dl=a.rho*(sl-ua), dr=b.rho*(sr-ub);
|
||||
@@ -333,7 +359,7 @@ public:
|
||||
}
|
||||
const double mass=f.m+speed*(density-q.rho);
|
||||
return {float(mass),float(f.p+speed*(density*sm-q.rho*q.velocity)),
|
||||
float(f.e+speed*(energy-(left?ea:eb))),float(mass*q.air),float(mass*q.fuel)};
|
||||
float(f.e+speed*(energy-(left?ea:eb))),float(mass*q.air),float(mass*q.fuel),float(mass*q.fuel_equivalent)};
|
||||
}
|
||||
static Flux numerical_flux(const GasState& a,const GasState& b,PipeSolver solver,
|
||||
float ca,float cb,bool* fallback=nullptr) {
|
||||
@@ -407,6 +433,7 @@ public:
|
||||
float en = cells[i].energy - factor * (fr.e - fl.e);
|
||||
const float air = cells[i].rho * cells[i].air - factor * (fr.y - fl.y);
|
||||
const float fuel = cells[i].rho * cells[i].fuel - factor * (fr.fuel - fl.fuel);
|
||||
const float equivalent=cells[i].rho*std::max(cells[i].fuel_equivalent,cells[i].fuel)-factor*(fr.fuel_equivalent-fl.fuel_equivalent);
|
||||
if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) ||
|
||||
!std::isfinite(air) || !std::isfinite(fuel))
|
||||
throw std::runtime_error("nonfinite finite-volume update");
|
||||
@@ -434,9 +461,10 @@ public:
|
||||
// subnormal concentrations; total gas mass and energy are unchanged.
|
||||
const float fraction = std::clamp(air / rho, 0.0f, 1.0f);
|
||||
const float fuel_fraction = std::clamp(fuel / rho, 0.0f, 1.0f - fraction);
|
||||
const float equivalent_fraction=std::clamp(equivalent/rho,fuel_fraction,1.f);
|
||||
next_cells_[i] = {rho, mom, en,
|
||||
fraction < std::numeric_limits<float>::min() ? 0.0f : fraction,
|
||||
fuel_fraction < std::numeric_limits<float>::min() ? 0.0f : fuel_fraction};
|
||||
fuel_fraction < std::numeric_limits<float>::min() ? 0.0f : fuel_fraction,equivalent_fraction<std::numeric_limits<float>::min()?0.f:equivalent_fraction};
|
||||
}
|
||||
cells.swap(next_cells_);
|
||||
prepared_ = false;
|
||||
@@ -444,11 +472,12 @@ public:
|
||||
left_override_ = right_override_ = false;
|
||||
}
|
||||
static Flux scaled(Flux f, float a) {
|
||||
return {f.m * a, f.p * a, f.e * a, f.y * a, f.fuel * a};
|
||||
return {f.m * a, f.p * a, f.e * a, f.y * a, f.fuel * a,f.fuel_equivalent*a};
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<GasCell> next_cells_;
|
||||
std::vector<float> geometry_cfl_lengths_;
|
||||
std::vector<GasState> states_;
|
||||
std::vector<GasState> reconstructed_left_, reconstructed_right_;
|
||||
std::vector<float> waves_;
|
||||
@@ -480,13 +509,14 @@ private:
|
||||
const float dp=minmod(q.pressure-a.pressure,b.pressure-q.pressure);
|
||||
const float da=minmod(q.air-a.air,b.air-q.air);
|
||||
const float df=minmod(q.fuel-a.fuel,b.fuel-q.fuel);
|
||||
const float de=minmod(q.fuel_equivalent-a.fuel_equivalent,b.fuel_equivalent-q.fuel_equivalent);
|
||||
const float h=.5f*dt/cell_lengths[i];
|
||||
const GasState half{q.rho-h*(q.velocity*dr+q.rho*du),
|
||||
q.velocity-h*(q.velocity*du+dp/q.rho),
|
||||
q.pressure-h*(q.velocity*dp+gamma*q.pressure*du),
|
||||
q.air-h*q.velocity*da,q.fuel-h*q.velocity*df};
|
||||
q.air-h*q.velocity*da,q.fuel-h*q.velocity*df,q.fuel_equivalent-h*q.velocity*de};
|
||||
const auto edge=[&](float sign) { return GasState{half.rho+sign*dr,
|
||||
half.velocity+sign*du,half.pressure+sign*dp,half.air+sign*da,half.fuel+sign*df}; };
|
||||
half.velocity+sign*du,half.pressure+sign*dp,half.air+sign*da,half.fuel+sign*df,half.fuel_equivalent+sign*de}; };
|
||||
const auto left=edge(-.5f), right=edge(.5f);
|
||||
const auto physical=[](const GasState& v) {
|
||||
return v.rho>1e-12f && v.pressure>1e-6f && std::isfinite(v.pressure) &&
|
||||
@@ -510,7 +540,7 @@ private:
|
||||
.5f * (ma * a.velocity + a.pressure + mb * b.velocity + b.pressure - c * (mb - ma)),
|
||||
.5f * ((ea + a.pressure) * a.velocity + (eb + b.pressure) * b.velocity - c * (eb - ea)),
|
||||
.5f * (ma * a.air + mb * b.air - c * (b.rho * b.air - a.rho * a.air)),
|
||||
.5f * (ma * a.fuel + mb * b.fuel - c * (b.rho * b.fuel - a.rho * a.fuel))};
|
||||
.5f * (ma * a.fuel + mb * b.fuel - c * (b.rho * b.fuel - a.rho * a.fuel)),.5f*(ma*a.fuel_equivalent+mb*b.fuel_equivalent-c*(b.rho*b.fuel_equivalent-a.rho*a.fuel_equivalent))};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -668,7 +698,7 @@ private:
|
||||
struct JunctionNode {
|
||||
std::vector<JunctionFace> ends;
|
||||
std::vector<GasState> states;
|
||||
struct Face { double pressure{},rho{},velocity{},mass{},enthalpy{},air{},fuel{},loss{}; };
|
||||
struct Face { double pressure{},rho{},velocity{},mass{},enthalpy{},air{},fuel{},loss{},equivalent{}; };
|
||||
std::vector<Face> faces;
|
||||
JunctionMethod method{JunctionMethod::Characteristic};
|
||||
double turning_loss{};
|
||||
@@ -689,7 +719,7 @@ private:
|
||||
s.velocity*=e.left?-1:1;node.states[k]=s;
|
||||
speed=std::max(speed,2*(std::sqrt(gamma*s.pressure/s.rho)+std::abs(double(s.velocity))));
|
||||
}
|
||||
double area_sum=0,rho=0,energy=0,air=0,fuel=0;
|
||||
double area_sum=0,rho=0,energy=0,air=0,fuel=0,equivalent=0;
|
||||
for(std::size_t k=0;k<node.ends.size();++k) {
|
||||
auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k];
|
||||
const double a=p.cell_area(end(p,e.left)), u=s.velocity;
|
||||
@@ -697,8 +727,10 @@ private:
|
||||
area_sum+=a;rho+=a*s.rho*(1+u/speed);
|
||||
energy+=a*(en+(en+s.pressure)*u/speed);
|
||||
air+=a*s.rho*s.air*(1+u/speed);fuel+=a*s.rho*s.fuel*(1+u/speed);
|
||||
equivalent+=a*s.rho*s.fuel_equivalent*(1+u/speed);
|
||||
}
|
||||
rho/=area_sum;energy/=area_sum;air/=area_sum;fuel/=area_sum;
|
||||
equivalent/=area_sum;
|
||||
const double pressure=energy*pipe_gm1;
|
||||
for(std::size_t k=0;k<node.ends.size();++k) {
|
||||
auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k];
|
||||
@@ -710,7 +742,8 @@ private:
|
||||
const double y=.5*a*(s.rho*s.air*u-speed*(air-s.rho*s.air));
|
||||
const double f=.5*a*(s.rho*s.fuel*u-speed*(fuel-s.rho*s.fuel));
|
||||
const double momentum=.5*a*(s.rho*u*u+s.pressure+pressure+speed*s.rho*u);
|
||||
p.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*heat),float(orientation*y),float(orientation*f)});
|
||||
const double marker=.5*a*(s.rho*s.fuel_equivalent*u-speed*(equivalent-s.rho*s.fuel_equivalent));
|
||||
p.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*heat),float(orientation*y),float(orientation*f),float(orientation*marker)});
|
||||
}
|
||||
}
|
||||
// Nonlinear outgoing acoustic-wave curves (shock or rarefaction), with
|
||||
@@ -743,7 +776,7 @@ private:
|
||||
};
|
||||
// Each pressure evaluation writes scratch face states only, never pipes.
|
||||
const auto residual=[&](double pressure) {
|
||||
double incoming=0, heat=0, air=0, fuel=0;
|
||||
double incoming=0, heat=0, air=0, fuel=0,equivalent=0;
|
||||
for(std::size_t k=0;k<node.ends.size();++k) {
|
||||
const auto q=node.states[k]; auto& f=node.faces[k];
|
||||
double p=pressure;
|
||||
@@ -757,6 +790,7 @@ private:
|
||||
f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity;
|
||||
f.enthalpy=gamma/(gamma-1)*p/f.rho+.5*f.velocity*f.velocity;
|
||||
f.air=q.air; f.fuel=q.fuel;
|
||||
f.equivalent=q.fuel_equivalent;if(f.mass>0)equivalent+=f.mass*f.equivalent;
|
||||
if(f.mass>0) { incoming+=f.mass; heat+=f.mass*f.enthalpy; air+=f.mass*q.air; fuel+=f.mass*q.fuel; }
|
||||
}
|
||||
double balance=0;
|
||||
@@ -769,6 +803,7 @@ private:
|
||||
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];
|
||||
f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity;
|
||||
f.enthalpy=h; f.air=air/incoming; f.fuel=fuel/incoming;
|
||||
f.equivalent=equivalent/incoming;
|
||||
}
|
||||
balance+=f.mass;
|
||||
}
|
||||
@@ -808,12 +843,13 @@ private:
|
||||
}
|
||||
}
|
||||
}
|
||||
double incoming=0, outgoing=0, heat=0, air=0, fuel=0, scale=1;
|
||||
double incoming=0, outgoing=0, heat=0, air=0, fuel=0, scale=1,equivalent=0;
|
||||
for(std::size_t k=0;k<node.ends.size();++k) {
|
||||
const auto& f=node.faces[k];
|
||||
if(!std::isfinite(f.mass) || !std::isfinite(f.enthalpy)) return false;
|
||||
if(std::abs(f.velocity)>=.98*std::sqrt(gamma*f.pressure/f.rho)) return false;
|
||||
if(f.mass>0) { incoming+=f.mass;heat+=f.mass*f.enthalpy;air+=f.mass*f.air;fuel+=f.mass*f.fuel; }
|
||||
if(f.mass>0)equivalent+=f.mass*f.equivalent;
|
||||
else outgoing-=f.mass;
|
||||
}
|
||||
if(incoming>1e-16 && outgoing<=1e-16) {
|
||||
@@ -821,12 +857,14 @@ private:
|
||||
// Residual tolerance at rest must not create a one-sided mass source.
|
||||
for(auto& face:node.faces) face.mass=0;
|
||||
incoming=heat=air=fuel=0;
|
||||
equivalent=0;
|
||||
}
|
||||
// Remove root roundoff by scaling all outflows together. A shared donor
|
||||
// budget scales advective fluxes together, preserving node conservation.
|
||||
for(std::size_t k=0;k<node.ends.size();++k) {
|
||||
auto& f=node.faces[k];
|
||||
if(f.mass<0) { f.mass*=outgoing>0?incoming/outgoing:0; f.enthalpy=incoming>0?heat/incoming:0; f.air=incoming>0?air/incoming:0; f.fuel=incoming>0?fuel/incoming:0; }
|
||||
if(f.mass<0)f.equivalent=incoming>0?equivalent/incoming:0;
|
||||
if(f.mass>0) {
|
||||
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];const int i=end(pipe,e.left);
|
||||
scale=std::min(scale,.25*pipe.cell_mass(i)/(f.mass*dt));
|
||||
@@ -838,7 +876,7 @@ private:
|
||||
const auto e=node.ends[k]; auto& pipe=pipes_[e.pipe]; const auto& f=node.faces[k];
|
||||
const double orientation=e.left?-1:1, m=f.mass*scale;
|
||||
const double momentum=m*f.velocity+f.pressure*pipe.cell_area(end(pipe,e.left));
|
||||
pipe.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*m*f.enthalpy),float(orientation*m*f.air),float(orientation*m*f.fuel)});
|
||||
pipe.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*m*f.enthalpy),float(orientation*m*f.air),float(orientation*m*f.fuel),float(orientation*m*f.equivalent)});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1036,17 +1074,19 @@ private:
|
||||
const float direction = o.left ? 1.f : -1.f;
|
||||
const float species = from ? v.air_fraction : s.air;
|
||||
const float fuel = from ? v.fuel_fraction : s.fuel;
|
||||
const float equivalent=from?std::max(v.fuel_equivalent_fraction,v.fuel_fraction):s.fuel_equivalent;
|
||||
const float signed_mass = direction * mdot;
|
||||
p.set_boundary_flux(o.left, {signed_mass,
|
||||
mdot * velocity + face_pressure * area + s.pressure * (face_area - area),
|
||||
signed_mass * h, signed_mass * species, signed_mass * fuel});
|
||||
ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel});
|
||||
signed_mass * h, signed_mass * species, signed_mass * fuel,signed_mass*equivalent});
|
||||
ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel,equivalent});
|
||||
v.mass_flow = -mdot;
|
||||
v.enthalpy = h;
|
||||
if (!from)
|
||||
v.air_fraction = s.air;
|
||||
if (!from)
|
||||
v.fuel_fraction = s.fuel;
|
||||
if(!from)v.fuel_equivalent_fraction=s.fuel_equivalent;
|
||||
}
|
||||
void resolve_open(Open &o, float dt) {
|
||||
auto &p = pipes_[o.pipe];
|
||||
@@ -1078,13 +1118,14 @@ private:
|
||||
if (!(temperature > 0)) throw std::runtime_error("ambient inflow exceeds stagnation enthalpy");
|
||||
face.rho = face.pressure/(gas_constant*temperature);
|
||||
face.air = 1; face.fuel = 0;
|
||||
face.fuel_equivalent=0;
|
||||
}
|
||||
}
|
||||
ghost(p, o.left, face);
|
||||
const float mass_flux = face.rho*face.velocity*o.area;
|
||||
const float enthalpy = gamma/(gamma-1)*face.pressure/face.rho + .5f*face.velocity*face.velocity;
|
||||
p.set_boundary_flux(o.left, {mass_flux, mass_flux*face.velocity+face.pressure*o.area,
|
||||
mass_flux*enthalpy, mass_flux*face.air, mass_flux*face.fuel});
|
||||
mass_flux*enthalpy, mass_flux*face.air, mass_flux*face.fuel,mass_flux*face.fuel_equivalent});
|
||||
o.last_volume_flow = direction*face.velocity*o.area;
|
||||
o.last_mass_flow = direction*mass_flux;
|
||||
o.last_face_pressure = face.pressure;
|
||||
@@ -1098,7 +1139,7 @@ private:
|
||||
const bool out = o.left ? s.velocity <= -c : s.velocity >= c;
|
||||
const bool ambient_inflow = o.left ? u >= 0 : u <= 0;
|
||||
GasState g = out ? s : GasState{rho, u, o.pressure, ambient_inflow ? 1.f : s.air,
|
||||
ambient_inflow ? 0.f : s.fuel};
|
||||
ambient_inflow ? 0.f : s.fuel,ambient_inflow?0.f:s.fuel_equivalent};
|
||||
ghost(p, o.left, g);
|
||||
const float a = std::min(o.area, p.cell_area(i));
|
||||
auto f = Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), a);
|
||||
@@ -1123,10 +1164,10 @@ private:
|
||||
auto f = Pipe1D::scaled(Pipe1D::numerical_flux(sa, sb, a.solver == b.solver ? a.solver : PipeSolver::Rusanov), area);
|
||||
a.set_boundary_flux(o.left_a,
|
||||
{da * f.m, f.p + sa.pressure * (a.cell_area(ia) - area), da * f.e,
|
||||
da * f.y, da * f.fuel});
|
||||
da * f.y, da * f.fuel,da*f.fuel_equivalent});
|
||||
b.set_boundary_flux(o.left_b,
|
||||
{db * f.m, f.p + sb.pressure * (b.cell_area(ib) - area), db * f.e,
|
||||
db * f.y, db * f.fuel});
|
||||
db * f.y, db * f.fuel,db*f.fuel_equivalent});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
31
include/FluidSim/FuelInjector.hpp
Normal file
31
include/FluidSim/FuelInjector.hpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include "FluidSim/MachinePorts.hpp"
|
||||
|
||||
namespace fluidsim::port {
|
||||
class Volume0D;
|
||||
// Initial delivery model: calibrated regulated rail, with declared ideal or
|
||||
// finite supply. Rail pressure dynamics and liquid plumbing are separate work.
|
||||
struct FuelInjectorConfig {
|
||||
enum class Supply {IdealRail,FiniteRail};
|
||||
Supply supply{Supply::FiniteRail};
|
||||
double maximum_mass_flow{.001}; // kg/s at the calibrated rail pressure
|
||||
double initial_fuel_mass{.1}; // kg, used only by FiniteRail
|
||||
float fuel_temperature{300}; // sensible energy of the vaporized fuel
|
||||
};
|
||||
struct FuelInjectorState {
|
||||
bool command_fault{},saturated{},empty{};
|
||||
double mass_flow{},delivered_mass{},remaining_mass{};
|
||||
};
|
||||
class FuelInjector {
|
||||
public:
|
||||
explicit FuelInjector(FuelInjectorConfig config={});
|
||||
// Continuous/batch-average delivery, not a sequential pulse-width model.
|
||||
// Adds conserved fuel species, tracer and sensible energy to its host.
|
||||
const FuelInjectorState& deliver(Volume0D& host,const SignalValue& command,
|
||||
double time,double dt);
|
||||
const FuelInjectorState& state()const{return state_;}
|
||||
private:
|
||||
FuelInjectorConfig config_;
|
||||
FuelInjectorState state_;
|
||||
};
|
||||
}
|
||||
288
include/FluidSim/Machine.hpp
Normal file
288
include/FluidSim/Machine.hpp
Normal file
@@ -0,0 +1,288 @@
|
||||
#pragma once
|
||||
#include "FluidSim/Engine.hpp"
|
||||
#include "FluidSim/PhysicalGeometry.hpp"
|
||||
#include "FluidSim/PipeSolver.hpp"
|
||||
#include "FluidSim/OutdoorAudio.hpp"
|
||||
#include "FluidSim/Ecu.hpp"
|
||||
#include "FluidSim/Carburetor.hpp"
|
||||
#include "FluidSim/FuelInjector.hpp"
|
||||
#include "FluidSim/PipeProfile.hpp"
|
||||
#include "FluidSim/MachinePorts.hpp"
|
||||
#include "FluidSim/Mechanics.hpp"
|
||||
#include "FluidSim/MachineSensors.hpp"
|
||||
#include "FluidSim/MachineLogic.hpp"
|
||||
#include <cstdint>
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace machine_math {
|
||||
Vec3 add(Vec3,Vec3);
|
||||
Vec3 subtract(Vec3,Vec3);
|
||||
Vec3 multiply(Vec3,float);
|
||||
float dot(Vec3,Vec3);
|
||||
Vec3 cross(Vec3,Vec3);
|
||||
float length(Vec3);
|
||||
Vec3 unit(Vec3);
|
||||
// Intrinsic XYZ Euler angles, in degrees. Rendering and picking use this transform.
|
||||
Vec3 rotate(Vec3,Vec3 degrees);
|
||||
Vec3 inverse_rotate(Vec3,Vec3 degrees);
|
||||
Vec3 rotate_euler_world(Vec3,unsigned axis,float degrees);
|
||||
}
|
||||
enum class MachinePartKind { Pipe, Chamber, Junction, Throttle, CheckValve, Cylinder, PistonPump, Reservoir, Cap, Carburetor, Ecu, Shaft, Flywheel, DriveMotor, Brake };
|
||||
inline constexpr int machine_part_kind_count=15;
|
||||
enum class MachineConnectionKind { Orifice, Inertance, Characteristic, Legacy, Control, Mechanical };
|
||||
inline constexpr int machine_connection_kind_count=6;
|
||||
const char* machine_part_name(MachinePartKind);
|
||||
const char* machine_part_key(MachinePartKind);
|
||||
const char* machine_connection_name(MachineConnectionKind);
|
||||
const char* machine_connection_key(MachineConnectionKind);
|
||||
struct MachineShaftConfig {
|
||||
float inertia{.15f},initial_speed{},initial_angle{};
|
||||
float friction_constant{},friction_viscous{},friction_quadratic{},starter_torque{};
|
||||
};
|
||||
struct MachineDriveConfig {float target_speed{100},torque{1};bool speed_control{true},enabled{true};};
|
||||
struct MachineBrakeConfig {float constant{1},viscous{},quadratic{};bool enabled{true};};
|
||||
struct MachineSensorEcuConfig {
|
||||
bool enabled{}; // False preserves the explicitly labeled legacy ECU.
|
||||
SensorEcuConfig::Strategy strategy{SensorEcuConfig::Strategy::Ignition};
|
||||
std::vector<float> fuel_rpm{0,6000},fuel_tps{0,1};
|
||||
std::vector<float> fuel_per_cycle{.000002f,.00002f,.000002f,.00002f};
|
||||
float displacement{.0003f},air_gas_constant{287},target_afr{14.7f};
|
||||
std::vector<float> ve_rpm{0,6000},ve_pressure{20000,100000},volumetric_efficiency{.9f,.9f,.9f,.9f};
|
||||
bool operator==(const MachineSensorEcuConfig&)const=default;
|
||||
};
|
||||
struct MachineInjectorConfig {
|
||||
std::uint32_t ecu{};
|
||||
std::string ecu_label;
|
||||
int cylinders{1}; // Number of calibrated cylinder doses delivered here.
|
||||
float maximum_flow{.001f},initial_mass{.1f},temperature{300};
|
||||
bool finite_supply{true};
|
||||
bool operator==(const MachineInjectorConfig&)const=default;
|
||||
};
|
||||
|
||||
struct MachinePart {
|
||||
std::uint32_t id{};
|
||||
MachinePartKind kind{MachinePartKind::Pipe};
|
||||
std::string name;
|
||||
Vec3 position{0,.25f,0}, rotation{}, size{.12f,.12f,.12f};
|
||||
// Pipe centerline in part-local metres. Length is always derived from it.
|
||||
std::vector<Vec3> points{{-.15f,0,0},{.15f,0,0}};
|
||||
// One diameter per route point, linearly interpolated along each segment.
|
||||
// Empty profiles are legacy/programmatic pipes using the two scalar values.
|
||||
std::vector<float> point_diameters;
|
||||
float diameter{.04f}, outlet_diameter{.04f};
|
||||
float pressure{101325}, temperature{300}, opening{1}, discharge{.8f};
|
||||
bool pedal_control{true};
|
||||
float cracking_pressure{100}, phase_degrees{}, maximum_lift{.008f};
|
||||
float intake_open{350}, intake_close{580}, exhaust_open{130}, exhaust_close{370};
|
||||
CylinderConfig cylinder{};
|
||||
FourStrokeCylinder::ValveDynamics valve{};
|
||||
FourStrokeCylinder::ValveDynamics exhaust_valve{};
|
||||
FourStrokeCylinder::CamProfile intake_profile,exhaust_profile;
|
||||
// Normalized body coordinates preserve imported port locations when resized.
|
||||
std::vector<Vec3> port_coordinates;
|
||||
// Stable identities paired with custom chamber/junction port coordinates.
|
||||
std::vector<std::string> port_keys;
|
||||
EcuConfig ecu;
|
||||
MachineSensorEcuConfig sensor_ecu;
|
||||
std::optional<MachineInjectorConfig> injector; // Explicit chamber attachment.
|
||||
float ignition_tdc_degrees{}; // Calibrated crank pickup angle, 0..360.
|
||||
CarburetorConfig carburetor;
|
||||
bool injection_enabled{true};
|
||||
// Junction method 0 stores gas in its visible box; 1/2 are zero-volume nodes.
|
||||
int junction_method{};
|
||||
float turning_loss{};
|
||||
MachineShaftConfig shaft;
|
||||
MachineDriveConfig drive;
|
||||
MachineBrakeConfig brake;
|
||||
};
|
||||
struct MachinePort {
|
||||
std::uint32_t part{};
|
||||
unsigned port{};
|
||||
bool operator==(const MachinePort&) const = default;
|
||||
};
|
||||
SensorEcuConfig machine_sensor_ecu_config(const MachinePart&);
|
||||
struct MachineLink {
|
||||
MachinePort a,b;
|
||||
MachineConnectionKind kind{MachineConnectionKind::Orifice};
|
||||
float discharge{1}, inertance_length{.01f}, loss{};
|
||||
};
|
||||
enum class MachineQuantity {Pressure,Temperature,MassFlow,AirFuelRatio,Lambda,ChargeAfr,Rpm,Torque,Power};
|
||||
inline constexpr std::size_t machine_probe_limit=8;
|
||||
inline constexpr float machine_afr_reading_limit=1000.f;
|
||||
enum class MachineReadingState {Valid,NoFuel,LeanLimit,FuelCut,AwaitingCharge};
|
||||
const char* machine_reading_state_name(MachineReadingState);
|
||||
const char* machine_quantity_name(MachineQuantity);
|
||||
const char* machine_quantity_unit(MachineQuantity);
|
||||
float machine_quantity_scale(MachineQuantity);
|
||||
bool machine_quantity_supported(const MachinePart&,MachineQuantity);
|
||||
struct MachineProbe {
|
||||
std::uint32_t id{},part{};
|
||||
std::string name;
|
||||
MachineQuantity quantity{MachineQuantity::Pressure};
|
||||
float position{.5f}; // fraction of pipe arclength; lumped parts ignore it
|
||||
float reference_afr{14.7f}; // stoichiometric AFR for the selected fuel
|
||||
bool generated_output{}; // Migrated legacy shaft torque/power excludes bench motor/load.
|
||||
bool operator==(const MachineProbe&)const=default;
|
||||
};
|
||||
struct MachineMeasurement {
|
||||
double time{};
|
||||
std::array<float,machine_probe_limit> values{}; // SI; undefined readings are NaN
|
||||
std::array<MachineReadingState,machine_probe_limit> states{};
|
||||
std::size_t count{};
|
||||
};
|
||||
struct MachineDocument {
|
||||
std::string name{"Untitled machine"};
|
||||
float cells_per_meter{40}, drive_rpm{}, shaft_inertia{.15f}, load_torque{};
|
||||
bool driven{};
|
||||
// Deprecated calibration fields above are read only by legacy migration.
|
||||
std::uint32_t primary_shaft{};
|
||||
float friction_constant{.15f},friction_viscous{.002f},friction_quadratic{},starter_torque{15};
|
||||
PipeSolver solver{PipeSolver::Rusanov};
|
||||
OutdoorScene scene{OutdoorScene::Wall};
|
||||
std::uint32_t next_id{1};
|
||||
std::vector<MachinePart> parts;
|
||||
std::vector<MachineLink> links;
|
||||
std::vector<MachineProbe> probes;
|
||||
std::vector<MachineSensor> sensors;
|
||||
std::uint32_t next_sensor_id{1};
|
||||
LogicGraph logic;
|
||||
MachinePart* find(std::uint32_t);
|
||||
const MachinePart* find(std::uint32_t) const;
|
||||
MachinePart& add(MachinePartKind,Vec3 position={0,.25f,0});
|
||||
MachineSensor& add_sensor(MachineSensorKind,std::uint32_t host,float position=.5f);
|
||||
MachineSensor* find_sensor(std::uint32_t);
|
||||
const MachineSensor* find_sensor(std::uint32_t)const;
|
||||
void remove_sensor(std::uint32_t);
|
||||
void remove(std::uint32_t);
|
||||
void disconnect(MachinePort);
|
||||
// At least one endpoint must be a pipe. Its centerline snaps to the other port.
|
||||
void connect(MachinePort,MachinePort,MachineConnectionKind=MachineConnectionKind::Orifice);
|
||||
void sync_connections(std::uint32_t moved_part=0);
|
||||
void transform_selection(const std::vector<std::uint32_t>& ids,Vec3 pivot,Vec3 translation,unsigned rotation_axis=0,float degrees=0);
|
||||
};
|
||||
bool machine_sensor_supported(const MachinePart&,MachineSensorKind,bool reference=false);
|
||||
MachineSensorStatus machine_sensor_binding(const MachineDocument&,const MachineSensor&);
|
||||
std::optional<Vec3> machine_sensor_position(const MachineDocument&,const MachineSensorTap&);
|
||||
void validate_machine_sensors(const MachineDocument&);
|
||||
bool machine_control_port(const MachinePart&,unsigned);
|
||||
MachinePortDescriptor machine_port_descriptor(const MachinePart&,unsigned);
|
||||
unsigned machine_port_index(const MachinePart&,const std::string& key);
|
||||
PortDomain machine_link_domain(const MachineDocument&,const MachineLink&);
|
||||
bool machine_rotational_part(MachinePartKind);
|
||||
std::uint32_t machine_shaft_for(const MachineDocument&,std::uint32_t part);
|
||||
std::uint32_t machine_primary_shaft(const MachineDocument&);
|
||||
bool machine_shaft_driven(const MachineDocument&,std::uint32_t shaft);
|
||||
// Convert an old shared-shaft document to visible mechanical parts once.
|
||||
void upgrade_machine_mechanics(MachineDocument&);
|
||||
bool machine_port_available(const MachineDocument&,MachinePort);
|
||||
unsigned machine_port_count(const MachinePart&);
|
||||
std::string machine_port_name(const MachinePart&,unsigned);
|
||||
Vec3 machine_local_port(const MachinePart&,unsigned);
|
||||
Vec3 machine_world_point(const MachinePart&,Vec3);
|
||||
Vec3 machine_local_point(const MachinePart&,Vec3);
|
||||
Vec3 machine_port_position(const MachinePart&,unsigned);
|
||||
std::vector<Vec3> machine_pipe_path(const MachinePart&);
|
||||
float machine_pipe_length(const MachinePart&);
|
||||
std::vector<float> machine_pipe_diameters(const MachinePart&);
|
||||
std::vector<PipeSection> machine_pipe_sections(const MachinePart&);
|
||||
float machine_pipe_diameter(const MachinePart&,float distance);
|
||||
void machine_insert_pipe_point(MachinePart&,std::size_t segment,Vec3 local_point);
|
||||
void machine_remove_pipe_point(MachinePart&,std::size_t point);
|
||||
float machine_part_volume(const MachinePart&);
|
||||
Vec3 machine_part_size(const MachinePart&);
|
||||
// Drafts may contain unconnected ports. Unconnected duct ends are explicitly capped at test time.
|
||||
void validate_machine(const MachineDocument&);
|
||||
std::vector<std::string> machine_diagnostics(const MachineDocument&);
|
||||
struct MachineConnectionPreview {
|
||||
bool allowed{};
|
||||
std::string message;
|
||||
std::optional<MachinePart> pipe;
|
||||
};
|
||||
// Dry-run the actual connection and validation without changing the design.
|
||||
MachineConnectionPreview machine_connection_preview(const MachineDocument&,MachinePort,MachinePort);
|
||||
bool machine_has_fuel_source(const MachineDocument&,std::uint32_t cylinder);
|
||||
struct MachineCheck {
|
||||
std::uint32_t part{};
|
||||
bool warning{};
|
||||
std::string title,advice;
|
||||
std::uint32_t sensor{};
|
||||
std::uint32_t logic_node{};
|
||||
};
|
||||
std::vector<MachineCheck> machine_checks(const MachineDocument&);
|
||||
std::filesystem::path machine_directory();
|
||||
std::vector<std::filesystem::path> machine_files();
|
||||
std::filesystem::path machine_assembly_directory();
|
||||
std::vector<std::filesystem::path> machine_assembly_files();
|
||||
// Center of the selected world geometry, including routed pipes.
|
||||
Vec3 machine_selection_center(const MachineDocument&,const std::vector<std::uint32_t>&);
|
||||
// Stores only selected parts, internal links and their probes, relative to the center.
|
||||
MachineDocument machine_extract_assembly(const MachineDocument&,const std::vector<std::uint32_t>&,std::string name={});
|
||||
// Inserts an independent copy atomically; returned IDs follow assembly.parts order.
|
||||
std::vector<std::uint32_t> machine_insert_assembly(MachineDocument&,const MachineDocument&,Vec3 center);
|
||||
MachineDocument load_machine(const std::filesystem::path&);
|
||||
void save_machine(const MachineDocument&,const std::filesystem::path&);
|
||||
MachineDocument machine_pump_example();
|
||||
MachineDocument machine_engine_example();
|
||||
MachineDocument machine_logic_example();
|
||||
MachineDocument machine_sensor_engine_example(bool injection=false);
|
||||
MachineDocument machine_speed_density_example();
|
||||
struct ScenarioExample;
|
||||
MachineDocument machine_from_engine(const ScenarioExample&);
|
||||
|
||||
struct MachinePartState {
|
||||
std::uint32_t id{};
|
||||
float pressure{101325}, temperature{300}, volume{}, flow{}, piston_fraction{}, intake_lift{}, exhaust_lift{}, opening{};
|
||||
std::vector<float> pipe_pressures;
|
||||
float fuel_mass{},fuel_flow{},afr{},burn_fraction{};
|
||||
bool combustion{},fuel_cut{},ignition_cut{},idle_control{},valve_float{},fuel_source{};
|
||||
bool actuator_enabled{},signal_controlled{};
|
||||
double command_target{};
|
||||
double injector_remaining{},injector_delivered{};
|
||||
bool sensor_fault{},rev_limited{},injector_fault{},injector_empty{};
|
||||
float spark_advance{};
|
||||
std::uint32_t shaft{};
|
||||
double shaft_angle{},shaft_speed{},rotational_inertia{},torque{},power{},kinetic_energy{},mechanical_work{},mechanical_loss{};
|
||||
};
|
||||
struct MachineSnapshot {
|
||||
double time{};
|
||||
float rpm{}, torque{}, power{};
|
||||
std::uint64_t cells{},substeps{},positivity_repairs{};
|
||||
float minimum_cell_length{};
|
||||
std::uint32_t minimum_cell_part{};
|
||||
std::vector<MachinePartState> parts;
|
||||
std::uint32_t primary_shaft{};
|
||||
std::vector<ShaftState> shafts;
|
||||
std::vector<MachineSensorState> sensors;
|
||||
std::vector<LogicNodeState> logic;
|
||||
};
|
||||
// Owns the compiled graph. Editing never mutates a running solver; Test compiles a fresh graph.
|
||||
class MachineSimulation {
|
||||
public:
|
||||
explicit MachineSimulation(const MachineDocument&,int sample_rate=44100);
|
||||
~MachineSimulation();
|
||||
MachineSimulation(const MachineSimulation&)=delete;
|
||||
MachineSimulation& operator=(const MachineSimulation&)=delete;
|
||||
AudioFrame process();
|
||||
void set_controls(float pedal,bool starter=false);
|
||||
void set_listener(AudioListener);
|
||||
MachineSnapshot snapshot() const;
|
||||
MachineMeasurement measure() const;
|
||||
// Latest sample-boundary values, independent of recorded instrument channels.
|
||||
MachineSensorState sense(std::uint32_t sensor)const;
|
||||
void set_logic_control(std::uint32_t node,bool);
|
||||
void set_logic_control(std::uint32_t node,double);
|
||||
void release_logic_buttons();
|
||||
// Applies safe tuning and instrument changes between samples, preserving state.
|
||||
// Geometry, topology, initial conditions and compiled boundary settings must match.
|
||||
void apply_tuning(const MachineDocument&);
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
111
include/FluidSim/MachineLogic.hpp
Normal file
111
include/FluidSim/MachineLogic.hpp
Normal file
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
#include "FluidSim/MachineSensors.hpp"
|
||||
#include "FluidSim/Ecu.hpp"
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fluidsim::port {
|
||||
struct MachineDocument;
|
||||
enum class LogicNodeKind {
|
||||
BooleanConstant,Switch,Button,NumberConstant,Slider,NumberInput,
|
||||
And,Or,Not,Xor,Nand,Nor,Greater,Less,Equal,Window,Hysteresis,
|
||||
Add,Subtract,Multiply,Divide,Minimum,Maximum,Absolute,Clamp,GainOffset,MapRange,Select,BooleanToNumber,
|
||||
Sensor,Command,Lamp,Readout,BooleanDelay,NumberDelay,Ecu
|
||||
};
|
||||
inline constexpr int logic_node_kind_count=36;
|
||||
enum class LogicCommand {MotorEnable,MotorSpeed,MotorTorque,BrakeEnable,ThrottleOpening,Starter};
|
||||
inline constexpr int logic_command_count=6;
|
||||
struct LogicPosition {float x{},y{};bool operator==(const LogicPosition&)const=default;};
|
||||
struct LogicNode {
|
||||
std::uint32_t id{};
|
||||
LogicNodeKind kind{LogicNodeKind::Switch};
|
||||
std::string name;
|
||||
LogicPosition position;
|
||||
SignalQuantity quantity{SignalQuantity::Scalar},output_quantity{SignalQuantity::Scalar};
|
||||
PressureReference pressure_reference{PressureReference::None},output_pressure_reference{PressureReference::None};
|
||||
double value{},minimum{},maximum{1},low{.25},high{.75},tolerance{1e-6};
|
||||
double gain{1},offset{},false_value{},true_value{1},input_low{},input_high{1},output_low{},output_high{1};
|
||||
bool boolean{},initial{},below{},clamp{true};
|
||||
unsigned input_count{2};
|
||||
std::uint32_t sensor{},part{};
|
||||
MachineSensorKind sensor_kind{MachineSensorKind::Pressure}; // Schema survives an unresolved reference.
|
||||
SensorEcuConfig::Strategy ecu_strategy{SensorEcuConfig::Strategy::Ignition};
|
||||
LogicCommand command{LogicCommand::MotorEnable};
|
||||
std::string binding_label;
|
||||
bool operator==(const LogicNode&)const=default;
|
||||
};
|
||||
struct LogicEndpoint {
|
||||
std::uint32_t node{};
|
||||
std::string port;
|
||||
bool operator==(const LogicEndpoint&)const=default;
|
||||
};
|
||||
struct LogicLink {
|
||||
LogicEndpoint from,to;
|
||||
bool operator==(const LogicLink&)const=default;
|
||||
};
|
||||
struct LogicGraph {
|
||||
std::uint32_t next_id{1};
|
||||
std::vector<LogicNode> nodes;
|
||||
std::vector<LogicLink> links;
|
||||
LogicNode* find(std::uint32_t);
|
||||
const LogicNode* find(std::uint32_t)const;
|
||||
LogicNode& add(LogicNodeKind,LogicPosition={});
|
||||
void remove(std::uint32_t);
|
||||
void disconnect(const LogicEndpoint&);
|
||||
void connect(LogicEndpoint,LogicEndpoint); // Canonical output -> input; validates without mutation on failure.
|
||||
bool operator==(const LogicGraph&)const=default;
|
||||
};
|
||||
const char* logic_node_key(LogicNodeKind);
|
||||
const char* logic_node_name(LogicNodeKind);
|
||||
const char* logic_command_key(LogicCommand);
|
||||
const char* logic_command_name(LogicCommand);
|
||||
bool logic_memory_node(LogicNodeKind);
|
||||
std::vector<MachinePortDescriptor> logic_node_ports(const LogicNode&,const MachineDocument* context=nullptr);
|
||||
MachinePortDescriptor logic_port(const LogicGraph&,const LogicEndpoint&);
|
||||
PortCompatibility logic_connection_preview(const LogicGraph&,LogicEndpoint,LogicEndpoint);
|
||||
void validate_logic_graph(const LogicGraph&); // Drafts may have required unconnected inputs.
|
||||
std::vector<std::uint32_t> logic_evaluation_order(const LogicGraph&,std::vector<std::uint32_t>* cycle_path=nullptr);
|
||||
std::vector<std::string> logic_required_inputs(const LogicGraph&);
|
||||
bool logic_topology_equal(const LogicGraph&,const LogicGraph&);
|
||||
// Empty means a compatible physical binding. Missing targets remain unresolved.
|
||||
std::string logic_binding_problem(const MachineDocument&,const LogicNode&);
|
||||
const LogicNode* logic_command_owner(const MachineDocument&,std::uint32_t part,LogicCommand);
|
||||
void validate_machine_logic(const MachineDocument&);
|
||||
struct LogicNodeState {
|
||||
std::uint32_t id{};
|
||||
std::vector<SignalValue> values; // Same order as logic_node_ports, including inputs.
|
||||
std::string problem;
|
||||
};
|
||||
struct LogicCommandState {
|
||||
std::uint32_t node{},part{};
|
||||
LogicCommand command{};
|
||||
SignalValue value; // Invalid values require the declared inactive consumer fallback.
|
||||
};
|
||||
struct LogicEcuInputs {
|
||||
std::uint32_t node{},part{};
|
||||
SignalValue speed,angle,tps,map,iat;
|
||||
};
|
||||
class MachineLogicRuntime {
|
||||
public:
|
||||
using SensorReader=std::function<MachineSensorState(std::uint32_t)>;
|
||||
explicit MachineLogicRuntime(const MachineDocument&,SensorReader={});
|
||||
~MachineLogicRuntime();
|
||||
MachineLogicRuntime(const MachineLogicRuntime&)=delete;
|
||||
MachineLogicRuntime& operator=(const MachineLogicRuntime&)=delete;
|
||||
void evaluate(double time,double dt);
|
||||
SignalValue value(const LogicEndpoint&)const;
|
||||
std::vector<LogicNodeState> snapshot()const;
|
||||
const std::vector<LogicCommandState>& commands()const;
|
||||
const std::vector<LogicEcuInputs>& ecu_inputs()const;
|
||||
void set_manual(std::uint32_t,bool);
|
||||
void set_manual(std::uint32_t,double);
|
||||
void release_buttons();
|
||||
void apply_tuning(const MachineDocument&);
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
51
include/FluidSim/MachinePorts.hpp
Normal file
51
include/FluidSim/MachinePorts.hpp
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
namespace fluidsim::port {
|
||||
enum class PortDomain {Gas,Rotational,Translational,Boolean,Number,EcuAssignment};
|
||||
enum class PortDirection {Bidirectional,Input,Output};
|
||||
enum class PortCardinality {Single,Multiple};
|
||||
// Quantities carry meaning as well as dimensions. Opening and scalar, or crank
|
||||
// angle and cycle phase, require an explicit conversion even when units match.
|
||||
enum class SignalQuantity {None,Scalar,Fraction,Pressure,Temperature,MassFlow,FreshAirMassFlow,AngularSpeed,Angle,CyclePhase,Torque,Power,Position,Velocity,Force,Time,Inertia};
|
||||
enum class PressureReference {None,Absolute,Gauge,Differential};
|
||||
enum class SignalValidity {Valid,Missing,Fault};
|
||||
// Runtime-only pickup evidence. Arithmetic creates new values without timing
|
||||
// authority; routing retains the original acquisition time, including delays.
|
||||
struct SignalTiming {
|
||||
std::uint32_t sensor{},shaft{};
|
||||
double sampled_at{},unwrapped_angle{},angular_speed{};
|
||||
bool operator==(const SignalTiming&)const=default;
|
||||
};
|
||||
struct SignalValue {
|
||||
std::variant<bool,double> value{false};
|
||||
SignalValidity validity{SignalValidity::Missing};
|
||||
double timestamp{};
|
||||
std::optional<SignalTiming> timing;
|
||||
static SignalValue boolean(bool,double timestamp=0);
|
||||
static SignalValue number(double,double timestamp=0);
|
||||
};
|
||||
struct MachinePortDescriptor {
|
||||
std::string key,name;
|
||||
PortDomain domain{PortDomain::Gas};
|
||||
PortDirection direction{PortDirection::Bidirectional};
|
||||
PortCardinality cardinality{PortCardinality::Single};
|
||||
SignalQuantity quantity{SignalQuantity::None};
|
||||
PressureReference pressure_reference{PressureReference::None};
|
||||
bool required{};
|
||||
std::variant<std::monostate,bool,double> default_value;
|
||||
std::optional<double> minimum,maximum;
|
||||
bool operator==(const MachinePortDescriptor&)const=default;
|
||||
};
|
||||
struct PortCompatibility {bool allowed{};std::string message;};
|
||||
const char* port_domain_key(PortDomain);
|
||||
const char* signal_quantity_key(SignalQuantity);
|
||||
const char* signal_quantity_unit(SignalQuantity); // Internal SI units.
|
||||
bool signal_domain(PortDomain);
|
||||
void validate_port_descriptor(const MachinePortDescriptor&);
|
||||
PortCompatibility port_compatibility(const MachinePortDescriptor&,const MachinePortDescriptor&);
|
||||
bool signal_value_valid_for(const SignalValue&,const MachinePortDescriptor&);
|
||||
}
|
||||
40
include/FluidSim/MachineSensors.hpp
Normal file
40
include/FluidSim/MachineSensors.hpp
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include "FluidSim/MachinePorts.hpp"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace fluidsim::port {
|
||||
enum class MachineSensorKind {Pressure,DifferentialPressure,GasTemperature,MassFlow,Crank,ThrottlePosition};
|
||||
inline constexpr int machine_sensor_kind_count=6;
|
||||
struct MachineSensorTap {
|
||||
std::uint32_t part{};
|
||||
float position{.5f}; // Normalized pipe arclength, independent of fluid cells.
|
||||
std::string label; // Retains the intended host when a binding is unresolved.
|
||||
bool operator==(const MachineSensorTap&)const=default;
|
||||
};
|
||||
struct MachineSensor {
|
||||
std::uint32_t id{};
|
||||
MachineSensorKind kind{MachineSensorKind::Pressure};
|
||||
std::string name;
|
||||
MachineSensorTap target,reference;
|
||||
float angle_offset{}; // Radians, added to the physical shaft angle.
|
||||
bool operator==(const MachineSensor&)const=default;
|
||||
};
|
||||
enum class MachineSensorStatus {Valid,MissingHost,IncompatibleHost,Fault};
|
||||
struct MachineSensorState {
|
||||
std::uint32_t id{};
|
||||
std::array<SignalValue,3> outputs;
|
||||
unsigned count{};
|
||||
MachineSensorStatus status{MachineSensorStatus::MissingHost};
|
||||
// An ideal crank pickup preserves provenance and unwrapped crossings. An
|
||||
// arbitrary numeric RPM signal must never acquire timing authority from it.
|
||||
std::uint32_t timing_shaft{};
|
||||
double unwrapped_angle{};
|
||||
};
|
||||
const char* machine_sensor_key(MachineSensorKind);
|
||||
const char* machine_sensor_name(MachineSensorKind);
|
||||
const char* machine_sensor_status_name(MachineSensorStatus);
|
||||
unsigned machine_sensor_output_count(MachineSensorKind);
|
||||
MachinePortDescriptor machine_sensor_output(const MachineSensor&,unsigned);
|
||||
}
|
||||
54
include/FluidSim/Mechanics.hpp
Normal file
54
include/FluidSim/Mechanics.hpp
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
namespace fluidsim::port {
|
||||
// Rotational quantities use SI: rad, rad/s, N m, kg m2, J and W.
|
||||
enum class DriveMode {Torque,Speed};
|
||||
struct ShaftParameters {
|
||||
double inertia{.15},initial_speed{},initial_angle{};
|
||||
double friction_constant{},friction_viscous{},friction_quadratic{};
|
||||
bool forward_only{};
|
||||
};
|
||||
struct DriveParameters {DriveMode mode{DriveMode::Torque};double command{};bool enabled{true};};
|
||||
struct BrakeParameters {double constant{},viscous{},quadratic{};bool enabled{true};};
|
||||
struct ShaftState {
|
||||
std::uint32_t id{};
|
||||
double angle{},previous_angle{},speed{},inertia{},kinetic_energy{};
|
||||
double net_torque{},source_torque{},external_torque{},friction_torque{},brake_torque{},constraint_torque{};
|
||||
double external_work{},drive_work{},friction_loss{},brake_loss{},initial_energy{};
|
||||
// initial_energy + external_work + drive_work = kinetic_energy + losses.
|
||||
};
|
||||
struct DriveState {
|
||||
std::uint32_t id{},shaft{};
|
||||
double torque{},power{},work{};
|
||||
};
|
||||
struct BrakeState {
|
||||
std::uint32_t id{},shaft{};
|
||||
double torque{},power{},loss{}; // Torque on shaft signed; absorbed power >= 0.
|
||||
};
|
||||
// Independent rigid shaft nodes. Gears and clutches will couple these nodes;
|
||||
// direct attachment adds torque or inertia to one named node exactly once.
|
||||
class MechanicalSystem {
|
||||
public:
|
||||
MechanicalSystem();
|
||||
~MechanicalSystem();
|
||||
MechanicalSystem(const MechanicalSystem&)=delete;
|
||||
MechanicalSystem& operator=(const MechanicalSystem&)=delete;
|
||||
void add_shaft(std::uint32_t,const ShaftParameters&);
|
||||
void add_flywheel(std::uint32_t id,std::uint32_t shaft,double inertia);
|
||||
void add_drive(std::uint32_t id,std::uint32_t shaft,const DriveParameters&);
|
||||
void add_brake(std::uint32_t id,std::uint32_t shaft,const BrakeParameters&);
|
||||
void set_drive(std::uint32_t,const DriveParameters&);
|
||||
void set_brake(std::uint32_t,const BrakeParameters&);
|
||||
void set_friction(std::uint32_t shaft,double constant,double viscous,double quadratic);
|
||||
void add_torque(std::uint32_t shaft,double torque);
|
||||
void step(double dt);
|
||||
const ShaftState& shaft(std::uint32_t)const;
|
||||
const DriveState& drive(std::uint32_t)const;
|
||||
const BrakeState& brake(std::uint32_t)const;
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
@@ -35,4 +35,31 @@ inline Pipe1D make_profiled_pipe(std::span<const PipeSection> sections, float ma
|
||||
}
|
||||
return Pipe1D(std::move(areas), std::move(lengths));
|
||||
}
|
||||
|
||||
// Uniform cells over the full arclength, integrating every profile section that
|
||||
// a cell crosses. Route/profile knots do not force short cells; gas volume is
|
||||
// still the exact sum of the piecewise frustums at any mesh density.
|
||||
inline Pipe1D make_uniform_profiled_pipe(std::span<const PipeSection> sections,float maximum_cell_length=.01f){
|
||||
if(!std::isfinite(maximum_cell_length)||maximum_cell_length<=0||sections.empty())throw std::invalid_argument("invalid pipe profile mesh");
|
||||
double total=0;float previous=sections.front().inlet_diameter;
|
||||
for(const auto& s:sections){
|
||||
if(!std::isfinite(s.length)||!std::isfinite(s.inlet_diameter)||!std::isfinite(s.outlet_diameter)||s.length<=0||s.inlet_diameter<=0||s.outlet_diameter<=0||std::abs(s.inlet_diameter-previous)>1e-6f)throw std::invalid_argument("invalid/discontinuous pipe profile");
|
||||
total+=s.length;previous=s.outlet_diameter;
|
||||
}
|
||||
const int count=profile_section_cells(float(total),maximum_cell_length);const double dx=total/count;
|
||||
std::vector<float> areas;areas.reserve(count);std::vector<float> lengths(count,float(dx));
|
||||
std::size_t section=0;double section_start=0,section_end=sections.front().length;
|
||||
for(int i=0;i<count;++i){
|
||||
double cursor=i*dx,volume=0;const double end=i+1==count?total:(i+1)*dx;
|
||||
while(cursor<end){
|
||||
while(section+1<sections.size()&&cursor>=section_end){section_start=section_end;section_end+=sections[++section].length;}
|
||||
const auto& s=sections[section];const double stop=std::min(end,section_end);
|
||||
const double d0=std::lerp(double(s.inlet_diameter),double(s.outlet_diameter),std::clamp((cursor-section_start)/s.length,0.,1.));
|
||||
const double d1=std::lerp(double(s.inlet_diameter),double(s.outlet_diameter),std::clamp((stop-section_start)/s.length,0.,1.));
|
||||
volume+=double(pi)/12*(stop-cursor)*(d0*d0+d0*d1+d1*d1);cursor=stop;
|
||||
}
|
||||
areas.push_back(float(volume/dx));
|
||||
}
|
||||
return Pipe1D(std::move(areas),std::move(lengths));
|
||||
}
|
||||
} // namespace fluidsim::port
|
||||
|
||||
@@ -19,6 +19,8 @@ public:
|
||||
bool write(float left, float right, std::stop_token stop = {});
|
||||
bool wait_for_space(std::size_t target_fill, std::stop_token stop);
|
||||
void reset_consumer();
|
||||
// Only while both producer and audio consumer are stopped.
|
||||
void discard();
|
||||
std::size_t read_interpolated(std::span<float> left, std::span<float> right, double speed);
|
||||
|
||||
private:
|
||||
|
||||
1117
machines/air-compressor.machine.toml
Normal file
1117
machines/air-compressor.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
422
machines/assemblies/3-cyl-header.assembly.toml
Normal file
422
machines/assemblies/3-cyl-header.assembly.toml
Normal file
@@ -0,0 +1,422 @@
|
||||
cells_per_meter = 10.0
|
||||
drive_rpm = 0.0
|
||||
driven = false
|
||||
format = 4
|
||||
friction_constant = 0.15000000596046448
|
||||
friction_quadratic = 0.0
|
||||
friction_viscous = 0.0020000000949949026
|
||||
load_torque = 0.0
|
||||
name = '3-cyl-header'
|
||||
scene = 2
|
||||
shaft_inertia = 0.15000000596046448
|
||||
solver = 'rusanov'
|
||||
starter_torque = 15.0
|
||||
|
||||
[[connections]]
|
||||
a_part = 31
|
||||
a_port = 1
|
||||
b_part = 30
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 32
|
||||
a_port = 0
|
||||
b_part = 30
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 33
|
||||
a_port = 1
|
||||
b_part = 30
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 50
|
||||
a_port = 0
|
||||
b_part = 30
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
ambient_temperature = 300.0
|
||||
bore_m = 0.075000002980232239
|
||||
burn_duration_deg = 60.0
|
||||
burn_start_deg = 5.0
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
energy_variation = 0.05000000074505806
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_profile = []
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
fuel_lhv = 44000000.0
|
||||
heat_transfer = 100.0
|
||||
id = 30
|
||||
injection_enabled = true
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_profile = []
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 1
|
||||
kind = 'junction'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
misfire_probability = 0.0
|
||||
name = 'Junction 30'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
port_coordinates = []
|
||||
position_m = [ -0.01974998414516449, -0.032500006258487701, 0.042499996721744537 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.059999998658895493, 0.059999998658895493, 0.059999998658895493 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
wiebe_a = 5.0
|
||||
wiebe_m = 2.0
|
||||
|
||||
[parts.exhaust_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[parts.intake_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
ambient_temperature = 300.0
|
||||
bore_m = 0.075000002980232239
|
||||
burn_duration_deg = 60.0
|
||||
burn_start_deg = 5.0
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
discharge = 0.80000001192092896
|
||||
energy_variation = 0.05000000074505806
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_profile = []
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
fuel_lhv = 44000000.0
|
||||
heat_transfer = 100.0
|
||||
id = 31
|
||||
injection_enabled = true
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_profile = []
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
misfire_probability = 0.0
|
||||
name = 'Pipe / reducer 31'
|
||||
opening = 1.0
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
point_diameters_m = [ 0.02500000037252903, 0.02500000037252903, 0.02500000037252903 ]
|
||||
points_m = [ [ 0.13300000131130219, -0.29750001430511475, -0.59825778007507324 ], [ 0.15052753686904907, -0.33734250068664551, -0.59909915924072266 ], [ 0.18999999761581421, -0.37000000476837158, -0.60000002384185791 ] ]
|
||||
port_coordinates = []
|
||||
position_m = [ -0.2097499817609787, 0.33749997615814209, 0.64249998331069946 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
wiebe_a = 5.0
|
||||
wiebe_m = 2.0
|
||||
|
||||
[parts.exhaust_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[parts.intake_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
ambient_temperature = 300.0
|
||||
bore_m = 0.075000002980232239
|
||||
burn_duration_deg = 60.0
|
||||
burn_start_deg = 5.0
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
discharge = 0.80000001192092896
|
||||
energy_variation = 0.05000000074505806
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_profile = []
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
fuel_lhv = 44000000.0
|
||||
heat_transfer = 100.0
|
||||
id = 32
|
||||
injection_enabled = true
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_profile = []
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
misfire_probability = 0.0
|
||||
name = 'Pipe / reducer 32'
|
||||
opening = 1.0
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
point_diameters_m = [ 0.02500000037252903, 0.02500000037252903, 0.02500000037252903, 0.02500000037252903 ]
|
||||
points_m = [ [ -0.36999994516372681, -0.37000000476837158, -0.52000004053115845 ], [ -0.4761640727519989, -0.34520614147186279, -0.51906377077102661 ], [ -0.52133440971374512, -0.32712483406066895, -0.5185772180557251 ], [ -0.53999996185302734, -0.29750001430511475, -0.51825779676437378 ] ]
|
||||
port_coordinates = []
|
||||
position_m = [ 0.35024997591972351, 0.33749997615814209, 0.5625 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
wiebe_a = 5.0
|
||||
wiebe_m = 2.0
|
||||
|
||||
[parts.exhaust_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[parts.intake_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
ambient_temperature = 300.0
|
||||
bore_m = 0.075000002980232239
|
||||
burn_duration_deg = 60.0
|
||||
burn_start_deg = 5.0
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
discharge = 0.80000001192092896
|
||||
energy_variation = 0.05000000074505806
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_profile = []
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
fuel_lhv = 44000000.0
|
||||
heat_transfer = 100.0
|
||||
id = 33
|
||||
injection_enabled = true
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_profile = []
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
misfire_probability = 0.0
|
||||
name = 'Pipe / reducer 33'
|
||||
opening = 1.0
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
point_diameters_m = [ 0.02500000037252903, 0.02500000037252903, 0.02500000037252903, 0.02500000037252903 ]
|
||||
points_m = [ [ 0.16699999570846558, -0.29750001430511475, 0.14174219965934753 ], [ 0.21503587067127228, -0.35698139667510986, 0.14158914983272552 ], [ 0.25817635655403137, -0.36491745710372925, 0.14149604737758636 ], [ 0.44999998807907104, -0.37000000476837158, 0.13999998569488525 ] ]
|
||||
port_coordinates = []
|
||||
position_m = [ -0.46974998712539673, 0.33749997615814209, -0.09749998152256012 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
wiebe_a = 5.0
|
||||
wiebe_m = 2.0
|
||||
|
||||
[parts.exhaust_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[parts.intake_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
ambient_temperature = 300.0
|
||||
bore_m = 0.075000002980232239
|
||||
burn_duration_deg = 60.0
|
||||
burn_start_deg = 5.0
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
discharge = 0.80000001192092896
|
||||
energy_variation = 0.05000000074505806
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_profile = []
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
fuel_lhv = 44000000.0
|
||||
heat_transfer = 100.0
|
||||
id = 50
|
||||
injection_enabled = true
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_profile = []
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
misfire_probability = 0.0
|
||||
name = 'Pipe / reducer 50'
|
||||
opening = 1.0
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
point_diameters_m = [ 0.029999999329447746, 0.029999999329447746 ]
|
||||
points_m = [ [ -0.46999996900558472, -0.37000000476837158, -0.2200000137090683 ], [ -0.15000000596046448, -0.29999998211860657, -0.31000000238418579 ] ]
|
||||
port_coordinates = []
|
||||
position_m = [ 0.45024999976158142, 0.33749997615814209, 0.26250001788139343 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
wiebe_a = 5.0
|
||||
wiebe_m = 2.0
|
||||
|
||||
[parts.exhaust_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[parts.intake_dynamics]
|
||||
cam_restitution = 0.0099999997764825821
|
||||
damping = 24.0
|
||||
mass_kg = 0.035000000149011612
|
||||
maximum_lift = 0.014999999664723873
|
||||
seat_force = 100.0
|
||||
seat_restitution = 0.004999999888241291
|
||||
spring_rate = 30000.0
|
||||
stop_restitution = 0.0099999997764825821
|
||||
|
||||
[[probes]]
|
||||
id = 1
|
||||
name = 'Lambda / Pipe / reducer 50'
|
||||
part = 50
|
||||
position = 0.5
|
||||
quantity = 4
|
||||
reference_afr = 14.699999809265137
|
||||
3293
machines/flat6-imported.machine.toml
Normal file
3293
machines/flat6-imported.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
1076
machines/logic-compressor-bench.machine.toml
Normal file
1076
machines/logic-compressor-bench.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
1465
machines/paralell-twin.machine.toml
Normal file
1465
machines/paralell-twin.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
1399
machines/sensor-alpha-n-bench.machine.toml
Normal file
1399
machines/sensor-alpha-n-bench.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
165
machines/sensor-bench.machine.toml
Normal file
165
machines/sensor-bench.machine.toml
Normal file
@@ -0,0 +1,165 @@
|
||||
format = 7
|
||||
name = 'Ideal sensor bench'
|
||||
primary_shaft = 6
|
||||
next_id = 8
|
||||
next_sensor_id = 7
|
||||
cells_per_meter = 40.0
|
||||
solver = 'rusanov'
|
||||
scene = 0
|
||||
|
||||
[[parts]]
|
||||
id = 1
|
||||
kind = 'reservoir'
|
||||
name = 'Compressed air supply'
|
||||
position_m = [-0.5, 0.25, 0.0]
|
||||
pressure_pa = 200000.0
|
||||
temperature_k = 300.0
|
||||
|
||||
[[parts]]
|
||||
id = 2
|
||||
kind = 'throttle'
|
||||
name = 'Fixed opening valve'
|
||||
position_m = [-0.1, 0.25, 0.0]
|
||||
size_m = [0.04, 0.05, 0.05]
|
||||
pedal_control = false
|
||||
opening = 0.45
|
||||
|
||||
[[parts]]
|
||||
id = 3
|
||||
kind = 'chamber'
|
||||
name = 'Receiving tank'
|
||||
position_m = [0.3, 0.25, 0.0]
|
||||
size_m = [0.12, 0.12, 0.12]
|
||||
|
||||
[[parts]]
|
||||
id = 4
|
||||
kind = 'pipe'
|
||||
name = 'Supply pipe'
|
||||
position_m = [0.0, 0.0, 0.0]
|
||||
points_m = [[-0.5, 0.25, 0.0], [-0.12, 0.25, 0.0]]
|
||||
point_diameters_m = [0.04, 0.04]
|
||||
|
||||
[[parts]]
|
||||
id = 5
|
||||
kind = 'pipe'
|
||||
name = 'Measured filling pipe'
|
||||
position_m = [0.0, 0.0, 0.0]
|
||||
points_m = [[-0.08, 0.25, 0.0], [0.24, 0.25, 0.0]]
|
||||
point_diameters_m = [0.04, 0.04]
|
||||
|
||||
[[parts]]
|
||||
id = 6
|
||||
kind = 'shaft'
|
||||
name = 'Pickup demonstration shaft'
|
||||
position_m = [0.1, 0.2, 0.3]
|
||||
size_m = [0.25, 0.035, 0.035]
|
||||
[parts.shaft]
|
||||
inertia_kg_m2 = 0.15
|
||||
|
||||
[[parts]]
|
||||
id = 7
|
||||
kind = 'drive_motor'
|
||||
name = 'Ideal speed drive'
|
||||
position_m = [-0.25, 0.2, 0.3]
|
||||
size_m = [0.12, 0.09, 0.09]
|
||||
[parts.drive]
|
||||
speed_control = true
|
||||
target_speed_rad_s = 188.49556
|
||||
enabled = true
|
||||
|
||||
[[connections]]
|
||||
a_part = 4
|
||||
a_port = 'start'
|
||||
b_part = 1
|
||||
b_port = 'gas'
|
||||
method = 'orifice'
|
||||
|
||||
[[connections]]
|
||||
a_part = 4
|
||||
a_port = 'end'
|
||||
b_part = 2
|
||||
b_port = 'inlet'
|
||||
method = 'orifice'
|
||||
|
||||
[[connections]]
|
||||
a_part = 5
|
||||
a_port = 'start'
|
||||
b_part = 2
|
||||
b_port = 'outlet'
|
||||
method = 'orifice'
|
||||
|
||||
[[connections]]
|
||||
a_part = 5
|
||||
a_port = 'end'
|
||||
b_part = 3
|
||||
b_port = 'face_x_negative'
|
||||
method = 'orifice'
|
||||
|
||||
[[connections]]
|
||||
a_part = 6
|
||||
a_port = 'shaft'
|
||||
b_part = 7
|
||||
b_port = 'shaft'
|
||||
method = 'mechanical'
|
||||
|
||||
[[sensors]]
|
||||
id = 1
|
||||
kind = 'pressure'
|
||||
name = 'Tank absolute pressure'
|
||||
target = {part = 3, position = 0.5, label = 'Receiving tank'}
|
||||
reference = {part = 0, position = 0.5, label = ''}
|
||||
|
||||
[[sensors]]
|
||||
id = 2
|
||||
kind = 'gas_temperature'
|
||||
name = 'Tank gas temperature'
|
||||
target = {part = 3, position = 0.5, label = 'Receiving tank'}
|
||||
reference = {part = 0, position = 0.5, label = ''}
|
||||
|
||||
[[sensors]]
|
||||
id = 3
|
||||
kind = 'differential_pressure'
|
||||
name = 'Supply minus tank pressure'
|
||||
target = {part = 1, position = 0.5, label = 'Compressed air supply'}
|
||||
reference = {part = 3, position = 0.5, label = 'Receiving tank'}
|
||||
|
||||
[[sensors]]
|
||||
id = 4
|
||||
kind = 'mass_flow'
|
||||
name = 'Signed total filling flow'
|
||||
target = {part = 5, position = 0.5, label = 'Measured filling pipe'}
|
||||
reference = {part = 0, position = 0.5, label = ''}
|
||||
|
||||
[[sensors]]
|
||||
id = 5
|
||||
kind = 'crank'
|
||||
name = 'Shaft speed and crank position'
|
||||
angle_offset_rad = 0.0
|
||||
target = {part = 6, position = 0.5, label = 'Pickup demonstration shaft'}
|
||||
reference = {part = 0, position = 0.5, label = ''}
|
||||
|
||||
[[sensors]]
|
||||
id = 6
|
||||
kind = 'throttle_position'
|
||||
name = 'Valve mechanism opening'
|
||||
target = {part = 2, position = 0.5, label = 'Fixed opening valve'}
|
||||
reference = {part = 0, position = 0.5, label = ''}
|
||||
|
||||
[[probes]]
|
||||
id = 1
|
||||
part = 3
|
||||
name = 'Tank pressure'
|
||||
quantity = 0
|
||||
|
||||
[[probes]]
|
||||
id = 2
|
||||
part = 5
|
||||
name = 'Total filling flow'
|
||||
quantity = 2
|
||||
position = 0.5
|
||||
|
||||
[[probes]]
|
||||
id = 3
|
||||
part = 6
|
||||
name = 'Demonstration shaft RPM'
|
||||
quantity = 6
|
||||
1357
machines/sensor-ignition-bench.machine.toml
Normal file
1357
machines/sensor-ignition-bench.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
1462
machines/sensor-speed-density-bench.machine.toml
Normal file
1462
machines/sensor-speed-density-bench.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
93
machines/shaft-drive-bench.machine.toml
Normal file
93
machines/shaft-drive-bench.machine.toml
Normal file
@@ -0,0 +1,93 @@
|
||||
format = 6
|
||||
name = 'Shaft, flywheel and brake bench'
|
||||
primary_shaft = 1
|
||||
cells_per_meter = 40.0
|
||||
solver = 'rusanov'
|
||||
scene = 0
|
||||
|
||||
[[parts]]
|
||||
id = 1
|
||||
kind = 'shaft'
|
||||
name = 'Bench shaft'
|
||||
position_m = [0.0, 0.22, 0.0]
|
||||
size_m = [0.25, 0.035, 0.035]
|
||||
[parts.shaft]
|
||||
inertia_kg_m2 = 0.15
|
||||
friction_nm = 0.15
|
||||
viscous_nm_s = 0.002
|
||||
|
||||
[[parts]]
|
||||
id = 2
|
||||
kind = 'flywheel'
|
||||
name = 'Flywheel'
|
||||
position_m = [0.32, 0.22, 0.0]
|
||||
size_m = [0.025, 0.16, 0.16]
|
||||
[parts.shaft]
|
||||
inertia_kg_m2 = 0.35
|
||||
|
||||
[[parts]]
|
||||
id = 3
|
||||
kind = 'drive_motor'
|
||||
name = 'Torque motor'
|
||||
position_m = [-0.32, 0.22, 0.0]
|
||||
size_m = [0.12, 0.09, 0.09]
|
||||
[parts.drive]
|
||||
speed_control = false
|
||||
torque_nm = 10.0
|
||||
enabled = true
|
||||
|
||||
[[parts]]
|
||||
id = 4
|
||||
kind = 'brake'
|
||||
name = 'Adjustable brake'
|
||||
position_m = [0.0, 0.22, 0.24]
|
||||
size_m = [0.07, 0.1, 0.1]
|
||||
[parts.brake]
|
||||
constant_nm = 2.0
|
||||
viscous_nm_s = 0.01
|
||||
enabled = true
|
||||
|
||||
[[connections]]
|
||||
a_part = 1
|
||||
a_port = 'shaft'
|
||||
b_part = 2
|
||||
b_port = 'shaft'
|
||||
method = 'mechanical'
|
||||
|
||||
[[connections]]
|
||||
a_part = 1
|
||||
a_port = 'shaft'
|
||||
b_part = 3
|
||||
b_port = 'shaft'
|
||||
method = 'mechanical'
|
||||
|
||||
[[connections]]
|
||||
a_part = 1
|
||||
a_port = 'shaft'
|
||||
b_part = 4
|
||||
b_port = 'shaft'
|
||||
method = 'mechanical'
|
||||
|
||||
[[probes]]
|
||||
id = 1
|
||||
part = 1
|
||||
name = 'Bench RPM'
|
||||
quantity = 6
|
||||
|
||||
[[probes]]
|
||||
id = 2
|
||||
part = 3
|
||||
name = 'Motor input power'
|
||||
quantity = 8
|
||||
|
||||
[[probes]]
|
||||
id = 3
|
||||
part = 4
|
||||
name = 'Brake absorbed power'
|
||||
quantity = 8
|
||||
|
||||
[[probes]]
|
||||
id = 4
|
||||
part = 1
|
||||
name = 'Shaft net torque'
|
||||
quantity = 7
|
||||
1134
machines/single-450-imported.machine.toml
Normal file
1134
machines/single-450-imported.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
560
machines/single-cylinder.machine.toml
Normal file
560
machines/single-cylinder.machine.toml
Normal file
@@ -0,0 +1,560 @@
|
||||
cells_per_meter = 40.0
|
||||
drive_rpm = 1800.0
|
||||
driven = true
|
||||
format = 1
|
||||
load_torque = 0.0
|
||||
name = 'Single cylinder engine bench'
|
||||
scene = 2
|
||||
shaft_inertia = 0.15000000596046448
|
||||
solver = 'rusanov'
|
||||
|
||||
[[connections]]
|
||||
a_part = 7
|
||||
a_port = 0
|
||||
b_part = 1
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 7
|
||||
a_port = 1
|
||||
b_part = 2
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 8
|
||||
a_port = 0
|
||||
b_part = 2
|
||||
b_port = 1
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 8
|
||||
a_port = 1
|
||||
b_part = 3
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 9
|
||||
a_port = 0
|
||||
b_part = 3
|
||||
b_port = 1
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 9
|
||||
a_port = 1
|
||||
b_part = 4
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 10
|
||||
a_port = 0
|
||||
b_part = 4
|
||||
b_port = 1
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 10
|
||||
a_port = 1
|
||||
b_part = 5
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 11
|
||||
a_port = 0
|
||||
b_part = 5
|
||||
b_port = 1
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 11
|
||||
a_port = 1
|
||||
b_part = 6
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 1
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'reservoir'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pressure / atmosphere 1'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ -0.64999997615814209, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.039999999105930328, 0.039999999105930328, 0.039999999105930328 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 2
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'throttle'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Throttle valve 2'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ -0.40000000596046448, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.039999999105930328, 0.05000000074505806, 0.05000000074505806 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 3
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'chamber'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Chamber / tank 3'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ -0.20000000298023224, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.10000000149011612, 0.079999998211860657, 0.079999998211860657 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 4
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'cylinder'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Engine cylinder 4'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ 0.0, 0.20000000298023224, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 5
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'chamber'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Chamber / tank 5'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ 0.20000000298023224, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.079999998211860657, 0.079999998211860657, 0.079999998211860657 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 6
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'reservoir'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pressure / atmosphere 6'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ 0.64999997615814209, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.039999999105930328, 0.039999999105930328, 0.039999999105930328 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.032000001519918442
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 7
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pipe / reducer 7'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.032000001519918442
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.11500000953674316, 0.0, 0.0 ], [ 0.11499994993209839, 0.0, 0.0 ] ]
|
||||
position_m = [ -0.53499996662139893, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.032000001519918442
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 8
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pipe / reducer 8'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.032000001519918442
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.064999997615814209, 0.0, 0.0 ], [ 0.064999997615814209, 0.0, 0.0 ] ]
|
||||
position_m = [ -0.31499999761581421, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.032000001519918442
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 9
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pipe / reducer 9'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.032000001519918442
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.066375002264976501, 0.031380951404571533, 0.0 ], [ 0.066375002264976501, -0.031380951404571533, 0.0 ] ]
|
||||
position_m = [ -0.083625003695487976, 0.26861906051635742, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.032000001519918442
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 10
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pipe / reducer 10'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.032000001519918442
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.071374997496604919, -0.031380951404571533, 0.0 ], [ 0.071374997496604919, 0.031380951404571533, 0.0 ] ]
|
||||
position_m = [ 0.088624998927116394, 0.26861906051635742, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.032000001519918442
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 11
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pipe / reducer 11'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.032000001519918442
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.20499998331069946, 0.0, 0.0 ], [ 0.20499998331069946, 0.0, 0.0 ] ]
|
||||
position_m = [ 0.44499999284744263, 0.30000001192092896, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
1465
machines/twin-imported.machine.toml
Normal file
1465
machines/twin-imported.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
4957
machines/v8-h-imported.machine.toml
Normal file
4957
machines/v8-h-imported.machine.toml
Normal file
File diff suppressed because it is too large
Load Diff
254
machines/vacuum-pump.machine.toml
Normal file
254
machines/vacuum-pump.machine.toml
Normal file
@@ -0,0 +1,254 @@
|
||||
cells_per_meter = 40.0
|
||||
drive_rpm = 900.0
|
||||
driven = true
|
||||
format = 1
|
||||
load_torque = 0.0
|
||||
name = 'Vacuum pump bench'
|
||||
scene = 2
|
||||
shaft_inertia = 0.15000000596046448
|
||||
solver = 'rusanov'
|
||||
|
||||
[[connections]]
|
||||
a_part = 4
|
||||
a_port = 0
|
||||
b_part = 1
|
||||
b_port = 1
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 4
|
||||
a_port = 1
|
||||
b_part = 2
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 5
|
||||
a_port = 0
|
||||
b_part = 2
|
||||
b_port = 1
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[connections]]
|
||||
a_part = 5
|
||||
a_port = 1
|
||||
b_part = 3
|
||||
b_port = 0
|
||||
discharge = 1.0
|
||||
inertance_length_m = 0.0099999997764825821
|
||||
loss = 0.0
|
||||
method = 0
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 1
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'chamber'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Chamber / tank 1'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ -0.40000000596046448, 0.2800000011920929, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.20000000298023224, 0.18000000715255737, 0.18000000715255737 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 2
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'piston_pump'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Piston pump 2'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ 0.0, 0.20000000298023224, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.039999999105930328
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 3
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'reservoir'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pressure / atmosphere 3'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.039999999105930328
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
|
||||
position_m = [ 0.40000000596046448, 0.31999999284744263, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.039999999105930328, 0.039999999105930328, 0.039999999105930328 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.032000001519918442
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 4
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pipe / reducer 4'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.032000001519918442
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.14137500524520874, 0.02138093113899231, 0.0 ], [ 0.14137500524520874, -0.021380960941314697, 0.0 ] ]
|
||||
position_m = [ -0.15862500667572021, 0.25861907005310059, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
|
||||
[[parts]]
|
||||
afr = 14.699999809265137
|
||||
bore_m = 0.075000002980232239
|
||||
compression = 11.5
|
||||
cracking_pressure_pa = 100.0
|
||||
diameter_m = 0.032000001519918442
|
||||
discharge = 0.80000001192092896
|
||||
exhaust_close_deg = 370.0
|
||||
exhaust_open_deg = 130.0
|
||||
exhaust_valve_count = 1
|
||||
exhaust_valve_m = 0.028000000864267349
|
||||
heat_transfer = 100.0
|
||||
id = 5
|
||||
intake_close_deg = 580.0
|
||||
intake_open_deg = 350.0
|
||||
intake_valve_count = 1
|
||||
intake_valve_m = 0.029999999329447746
|
||||
junction_method = 0
|
||||
kind = 'pipe'
|
||||
maximum_lift_m = 0.0080000003799796104
|
||||
name = 'Pipe / reducer 5'
|
||||
opening = 1.0
|
||||
outlet_diameter_m = 0.032000001519918442
|
||||
pedal_control = true
|
||||
phase_deg = 0.0
|
||||
points_m = [ [ -0.1913750022649765, -0.041380941867828369, 0.0 ], [ 0.1913750022649765, 0.041380941867828369, 0.0 ] ]
|
||||
position_m = [ 0.20862500369548798, 0.27861905097961426, 0.0 ]
|
||||
pressure_pa = 101325.0
|
||||
rod_m = 0.1120000034570694
|
||||
rotation_deg = [ 0.0, 0.0, 0.0 ]
|
||||
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
|
||||
spark_advance_deg = 20.0
|
||||
stroke_m = 0.068000003695487976
|
||||
temperature_k = 300.0
|
||||
turning_loss = 0.0
|
||||
valve_damping = 24.0
|
||||
valve_mass_kg = 0.035000000149011612
|
||||
valve_preload_n = 100.0
|
||||
valve_spring_n_m = 30000.0
|
||||
wall_area_m2 = 0.019999999552965164
|
||||
61
src/builder_geometry.cpp
Normal file
61
src/builder_geometry.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
#include "FluidSim/Desktop/MachineBuilder.hpp"
|
||||
#include "FluidSim/Constants.hpp"
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
namespace fluidsim::port::desktop {
|
||||
using namespace machine_math;
|
||||
BuilderRay builder_ray(const OrbitCamera& c,sf::FloatRect v,sf::Vector2f screen){
|
||||
const float x=(2*(screen.x-v.position.x)/v.size.x-1)*.41421356f*v.size.x/v.size.y;
|
||||
const float y=(1-2*(screen.y-v.position.y)/v.size.y)*.41421356f;
|
||||
const float yaw=c.yaw*pi/180,pitch=c.pitch*pi/180;
|
||||
const Vec3 right{std::cos(yaw),0,std::sin(yaw)},up{std::sin(pitch)*std::sin(yaw),std::cos(pitch),-std::sin(pitch)*std::cos(yaw)},back{-std::cos(pitch)*std::sin(yaw),std::sin(pitch),std::cos(pitch)*std::cos(yaw)};
|
||||
return {c.listener().position,unit(add(add(multiply(right,x),multiply(up,y)),multiply(back,-1)))};
|
||||
}
|
||||
std::optional<sf::Vector2f> builder_project(const OrbitCamera& c,sf::FloatRect v,Vec3 world){
|
||||
const auto d=subtract(world,c.target);const float yaw=c.yaw*pi/180,pitch=c.pitch*pi/180;
|
||||
const float x=std::cos(yaw)*d.x+std::sin(yaw)*d.z,z=-std::sin(yaw)*d.x+std::cos(yaw)*d.z;
|
||||
const float y=std::cos(pitch)*d.y-std::sin(pitch)*z,depth=c.distance-(std::sin(pitch)*d.y+std::cos(pitch)*z);
|
||||
if(depth<.03f)return std::nullopt;
|
||||
const float scale=v.size.y/(2*.41421356f*depth);
|
||||
return sf::Vector2f{v.position.x+v.size.x*.5f+x*scale,v.position.y+v.size.y*.5f-y*scale};
|
||||
}
|
||||
std::optional<BuilderPipeLocation> builder_pipe_location(const OrbitCamera& c,sf::FloatRect viewport,const MachinePart& p,sf::Vector2f screen){
|
||||
if(p.kind!=MachinePartKind::Pipe)return std::nullopt;
|
||||
std::optional<BuilderPipeLocation> result;
|
||||
const auto depth=[&](Vec3 world){const auto d=subtract(world,c.target);const float yaw=c.yaw*pi/180,pitch=c.pitch*pi/180;return c.distance-(std::sin(pitch)*d.y+std::cos(pitch)*(-std::sin(yaw)*d.x+std::cos(yaw)*d.z));};
|
||||
for(std::size_t i=1;i<p.points.size();++i){
|
||||
const auto wa=machine_world_point(p,p.points[i-1]),wb=machine_world_point(p,p.points[i]);const auto a=builder_project(c,viewport,wa),b=builder_project(c,viewport,wb);if(!a||!b)continue;
|
||||
const auto edge=*b-*a;const float squared=edge.x*edge.x+edge.y*edge.y;if(squared<1e-5f)continue;
|
||||
const auto delta=screen-*a;const float u=std::clamp((delta.x*edge.x+delta.y*edge.y)/squared,0.f,1.f);const auto offset=screen-(*a+edge*u);const float distance=std::sqrt(offset.x*offset.x+offset.y*offset.y);
|
||||
const float t=u*depth(wa)/((1-u)*depth(wb)+u*depth(wa));
|
||||
if(!result||distance<result->screen_distance)result=BuilderPipeLocation{i-1,add(p.points[i-1],multiply(subtract(p.points[i],p.points[i-1]),t)),distance};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::optional<float> builder_pick_part(const MachinePart& p,BuilderRay ray,float tolerance){
|
||||
ray.origin=machine_local_point(p,ray.origin);ray.direction=inverse_rotate(ray.direction,p.rotation);
|
||||
if(p.kind==MachinePartKind::Pipe){
|
||||
float best=std::numeric_limits<float>::max();const auto diameters=machine_pipe_diameters(p);
|
||||
for(std::size_t i=1;i<p.points.size();++i){auto a=p.points[i-1],b=p.points[i],edge=subtract(b,a),q=subtract(ray.origin,a);const float ee=dot(edge,edge),de=dot(ray.direction,edge),dq=dot(ray.direction,q),eq=dot(edge,q),den=ee-de*de;
|
||||
float u=den>1e-10f?(eq-de*dq)/den:0;u=std::clamp(u,0.f,1.f);float t=std::max(0.f,dot(subtract(add(a,multiply(edge,u)),ray.origin),ray.direction));
|
||||
// Re-clamp the finite segment after solving for the half ray.
|
||||
u=std::clamp(dot(subtract(add(ray.origin,multiply(ray.direction,t)),a),edge)/ee,0.f,1.f);
|
||||
const float radius=std::lerp(diameters[i-1],diameters[i],u)*.5f+tolerance;
|
||||
if(length(subtract(add(ray.origin,multiply(ray.direction,t)),add(a,multiply(edge,u))))<=radius)best=std::min(best,std::max(0.f,t-radius));
|
||||
}return best==std::numeric_limits<float>::max()?std::nullopt:std::optional<float>(best);
|
||||
}
|
||||
Vec3 size=machine_part_size(p);if(p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Cap)size={.032f,.032f,.032f};
|
||||
if(p.kind==MachinePartKind::Junction&&p.junction_method)size={.035f,.035f,.035f};
|
||||
const float origin[]{ray.origin.x,ray.origin.y,ray.origin.z},dir[]{ray.direction.x,ray.direction.y,ray.direction.z},half[]{size.x*.5f+tolerance,size.y*.5f+tolerance,size.z*.5f+tolerance};float enter=0,exit=1e9f;
|
||||
for(int i=0;i<3;++i){if(std::abs(dir[i])<1e-8f){if(std::abs(origin[i])>half[i])return std::nullopt;}else{float a=(-half[i]-origin[i])/dir[i],b=(half[i]-origin[i])/dir[i];if(a>b)std::swap(a,b);enter=std::max(enter,a);exit=std::min(exit,b);if(exit<enter)return std::nullopt;}}
|
||||
return enter;
|
||||
}
|
||||
Vec3 builder_rotate_world(Vec3 e,unsigned axis,float degrees){
|
||||
const Vec3 a=axis==0?Vec3{1,0,0}:axis==1?Vec3{0,1,0}:Vec3{0,0,1};const float c=std::cos(degrees*pi/180),s=std::sin(degrees*pi/180);
|
||||
const auto turn=[&](Vec3 v){return add(add(multiply(v,c),multiply(cross(a,v),s)),multiply(a,dot(a,v)*(1-c)));};
|
||||
const auto x=turn(rotate({1,0,0},e)),y=turn(rotate({0,1,0},e)),z=turn(rotate({0,0,1},e));
|
||||
const float pitch=std::asin(std::clamp(-x.z,-1.f,1.f));
|
||||
if(std::abs(std::cos(pitch))<1e-5f)return {0,pitch*180/pi,std::atan2(-y.x,y.y)*180/pi};
|
||||
return {std::atan2(y.z,z.z)*180/pi,pitch*180/pi,std::atan2(x.y,x.x)*180/pi};
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ float smoothstep(float value) {
|
||||
}
|
||||
}
|
||||
|
||||
Carburetor::Carburetor(float throat_diameter)
|
||||
: bowl_mass_(bowl_capacity), throat_area_(circle_area(std::max(throat_diameter, 1e-4f))) {}
|
||||
Carburetor::Carburetor(float throat_diameter,const CarburetorConfig& config)
|
||||
: CarburetorConfig(config),bowl_mass_(bowl_capacity), throat_area_(circle_area(std::max(throat_diameter, 1e-4f))) {}
|
||||
|
||||
void Carburetor::step(float dt, float pedal, float slide, float rpm, float air_mass_flow,
|
||||
float air_density, float upstream_pressure, float downstream_pressure) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "FluidSim/Desktop/DesktopUi.hpp"
|
||||
#include "FluidSim/Desktop/ScenarioMenu.hpp"
|
||||
#include "FluidSim/Desktop/PlaybackControl.hpp"
|
||||
#include "FluidSim/Desktop/MachineBuilder.hpp"
|
||||
#include "FluidSim/Desktop/WindowPresentation.hpp"
|
||||
|
||||
#include <SFML/Graphics.hpp>
|
||||
#include <algorithm>
|
||||
@@ -34,6 +36,8 @@ constexpr float throttle_lerp_rate = 10.0f;
|
||||
int main(int argc, char** argv) try {
|
||||
using namespace fluidsim::port;
|
||||
bool scenario_from_args=false;
|
||||
bool builder_from_args=false,builder_play=false;
|
||||
std::filesystem::path machine_file;
|
||||
std::string junction_model;
|
||||
std::optional<float> junction_loss;
|
||||
auto examples=scenario_examples();
|
||||
@@ -42,6 +46,8 @@ int main(int argc, char** argv) try {
|
||||
OutdoorScene selected_scene=OutdoorScene::Wall;
|
||||
for(int i=1;i<argc;++i){
|
||||
const std::string option=argv[i];
|
||||
if(option=="--builder"){builder_from_args=true;continue;}
|
||||
if((option=="--machine"||option=="--play-machine")&&i+1<argc){builder_from_args=true;builder_play=option=="--play-machine";machine_file=argv[++i];continue;}
|
||||
if((option=="--example"||option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--solver"||option=="--junctions"||option=="--junction-loss")&&i+1<argc){
|
||||
const std::string value=argv[++i];
|
||||
if(option=="--example") {
|
||||
@@ -69,11 +75,11 @@ int main(int argc, char** argv) try {
|
||||
}
|
||||
}else{std::cerr<<"Usage: fluidsim_desktop [--example catalog-id] [--scenario twin|two-stroke|b205|v8] [--scene dry|field|wall|woodland|tunnel] [--exhaust racing|straight] [--solver rusanov|hlle|hllc|muscl] [--junctions volume|characteristic|legacy] [--junction-loss 0..10]\n";return 2;}
|
||||
}
|
||||
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height}
|
||||
: sf::Vector2u{720, 606};
|
||||
const sf::Vector2u initial_size{window_width,window_height};
|
||||
sf::ContextSettings context;
|
||||
context.depthBits=24;context.antiAliasingLevel=4;context.majorVersion=2;context.minorVersion=1;
|
||||
sf::RenderWindow window(sf::VideoMode(initial_size), "FluidSim",sf::Style::Default,sf::State::Windowed,context);
|
||||
desktop::maximize_window(window);
|
||||
window.setFramerateLimit(0);
|
||||
window.setVerticalSyncEnabled(true);
|
||||
window.setKeyRepeatEnabled(false);
|
||||
@@ -89,22 +95,23 @@ int main(int argc, char** argv) try {
|
||||
for(const auto& path:font_paths)if(std::filesystem::exists(path)&&font.openFromFile(path)){has_font=true;break;}
|
||||
if(!has_font)std::cerr<<"No UI font available; configure FLUIDSIM_FONT_FILE.\n";
|
||||
const sf::Font* font_ptr=has_font?&font:nullptr;
|
||||
if(builder_from_args){desktop::run_machine_builder(window,font_ptr,machine_file,builder_play);return 0;}
|
||||
|
||||
std::unordered_map<std::string,float> scenario_resolution;
|
||||
while (window.isOpen()) {
|
||||
if (!scenario_from_args) {
|
||||
window.setTitle("FluidSim - Examples");
|
||||
window.setMinimumSize(sf::Vector2u{720,606});
|
||||
window.setSize({720,606});
|
||||
window.setTitle("FluidSim - Main menu");
|
||||
window.setMinimumSize(sf::Vector2u{1280,720});
|
||||
desktop::maximize_window(window);
|
||||
const auto choice = desktop::choose_scenario(window, font_ptr,examples);
|
||||
if (!choice) return 0;
|
||||
selected=examples[*choice];
|
||||
window.setSize({window_width, window_height});
|
||||
window.setView(sf::View(sf::FloatRect({0, 0}, {float(window_width), float(window_height)})));
|
||||
if(!choice->example){desktop::run_machine_builder(window,font_ptr,choice->machine,choice->play);continue;}
|
||||
selected=examples[*choice->example];
|
||||
}
|
||||
scenario_from_args=false;
|
||||
window.setTitle("FluidSim - "+selected.title);
|
||||
window.setMinimumSize(sf::Vector2u{1040,800});
|
||||
desktop::maximize_window(window);
|
||||
if(!junction_model.empty()) selected.junctions.model=junction_model;
|
||||
if(junction_loss) selected.junctions.turning_loss=*junction_loss;
|
||||
if(selected.kind==ScenarioKind::TwoStroke && (!junction_model.empty() || junction_loss))
|
||||
|
||||
137
src/ecu.cpp
137
src/ecu.cpp
@@ -1,15 +1,65 @@
|
||||
#include "FluidSim/Ecu.hpp"
|
||||
#include "FluidSim/Constants.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
const char* sensor_ecu_strategy_key(SensorEcuConfig::Strategy strategy){
|
||||
switch(strategy){case SensorEcuConfig::Strategy::Ignition:return "ignition";case SensorEcuConfig::Strategy::AlphaN:return "alpha_n";case SensorEcuConfig::Strategy::SpeedDensity:return "speed_density";}throw std::invalid_argument("Unknown sensor ECU strategy");
|
||||
}
|
||||
const char* sensor_ecu_strategy_name(SensorEcuConfig::Strategy strategy){
|
||||
switch(strategy){case SensorEcuConfig::Strategy::Ignition:return "Crank ignition";case SensorEcuConfig::Strategy::AlphaN:return "Alpha-N EFI";case SensorEcuConfig::Strategy::SpeedDensity:return "Speed-density EFI";}throw std::invalid_argument("Unknown sensor ECU strategy");
|
||||
}
|
||||
SensorEcuConfig::Strategy sensor_ecu_strategy_from_key(std::string_view key){
|
||||
for(int i=0;i<sensor_ecu_strategy_count;++i){if(key==sensor_ecu_strategy_key(SensorEcuConfig::Strategy(i)))return SensorEcuConfig::Strategy(i);}
|
||||
throw std::invalid_argument("Unknown sensor ECU strategy");
|
||||
}
|
||||
SensorEcuConfig::Strategy next_sensor_ecu_strategy(SensorEcuConfig::Strategy strategy){sensor_ecu_strategy_key(strategy);return SensorEcuConfig::Strategy((int(strategy)+1)%sensor_ecu_strategy_count);}
|
||||
|
||||
std::optional<SignalTiming> ecu_crank_sample(const SignalValue& speed,const SignalValue& angle,double time){
|
||||
if(!std::isfinite(time)||speed.validity!=SignalValidity::Valid||angle.validity!=SignalValidity::Valid||
|
||||
speed.timestamp!=time||angle.timestamp!=time||!speed.timing||!angle.timing||*speed.timing!=*angle.timing||
|
||||
!std::holds_alternative<double>(speed.value)||!std::holds_alternative<double>(angle.value))return {};
|
||||
const auto& pickup=*speed.timing;
|
||||
if(!pickup.sensor||!pickup.shaft||pickup.sampled_at!=time||!std::isfinite(pickup.unwrapped_angle)||
|
||||
!std::isfinite(pickup.angular_speed)||pickup.angular_speed<0||std::get<double>(speed.value)!=pickup.angular_speed)return {};
|
||||
// Sensors use the solver's float pi convention; tolerate its rounding while
|
||||
// rejecting changed angle values that no longer describe the pickup.
|
||||
constexpr double period=2*double(pi);
|
||||
double wrapped=std::fmod(pickup.unwrapped_angle,period);if(wrapped<0)wrapped+=period;
|
||||
const double measured=std::get<double>(angle.value);
|
||||
if(!std::isfinite(measured)||measured<0||measured>=period||std::abs(measured-wrapped)>1e-9)return {};
|
||||
return pickup;
|
||||
}
|
||||
|
||||
CrankIgnitionClock::Result CrankIgnitionClock::sample(const SignalValue& speed,const SignalValue& angle,double time,double advance,double tdc){
|
||||
const auto current=ecu_crank_sample(speed,angle,time);
|
||||
if(!current||!std::isfinite(advance)||advance< -90||advance>90||!std::isfinite(tdc)||tdc<0||tdc>=360){reset();return {};}
|
||||
constexpr double period=2*double(pi);
|
||||
const double offset=(advance-tdc)*period/360;
|
||||
const double current_cycle=std::floor((current->unwrapped_angle+offset)/period);
|
||||
if(!previous_){previous_=current;last_event_cycle_=current_cycle;return {true,0};}
|
||||
const auto before=*previous_;
|
||||
if(current->sensor!=before.sensor||current->shaft!=before.shaft||time<=before.sampled_at||current->unwrapped_angle<before.unwrapped_angle){reset();return {};}
|
||||
const double previous_cycle=std::floor((before.unwrapped_angle+offset)/period);
|
||||
// A live advance change must not spark twice in one revolution by moving
|
||||
// the trigger across a previously fired cycle's TDC reference.
|
||||
const double crossed=std::max(0.,current_cycle-std::max(previous_cycle,*last_event_cycle_));
|
||||
if(!std::isfinite(crossed)||crossed>std::numeric_limits<unsigned>::max()){reset();return {};}
|
||||
previous_=current;
|
||||
if(crossed>0)last_event_cycle_=current_cycle;
|
||||
return {true,static_cast<unsigned>(crossed)};
|
||||
}
|
||||
|
||||
void Ecu::step(float dt, float pedal, float rpm) {
|
||||
fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) || !std::isfinite(rpm) || rpm < 0.0f;
|
||||
if (fault_) { target_ = 0.0f; idle_ = false; return; }
|
||||
pedal = std::clamp(pedal, 0.0f, 1.0f);
|
||||
if (!rev_limit_enabled) limiter_ = false;
|
||||
else if (rpm >= rev_limit_rpm) limiter_ = true;
|
||||
else if (rpm <= rev_resume_rpm) limiter_ = false;
|
||||
if (!rev_limit_enabled) {limiter_ = false;limiter_time_=0;}
|
||||
else if (!limiter_ && rpm >= rev_limit_rpm) {limiter_=true;limiter_time_=std::max(0.f,rev_limit_cut_time);}
|
||||
else if (limiter_) {limiter_time_=std::max(0.f,limiter_time_-dt);if(rpm<=rev_resume_rpm&&limiter_time_<=0)limiter_=false;}
|
||||
if (pedal > 0.005f || rpm < overrun_resume_rpm) overrun_ = false;
|
||||
else if (rpm > overrun_cut_rpm) overrun_ = true;
|
||||
|
||||
@@ -58,4 +108,85 @@ float Ecu::fuel_for_air(float air) const {
|
||||
if (fuel_cut() || !std::isfinite(air) || air <= 0.0f) return 0.0f;
|
||||
return air / std::max(afr_, 1.0f);
|
||||
}
|
||||
|
||||
namespace {
|
||||
void require_calibration(bool ok,const char* why){if(!ok)throw std::invalid_argument(why);}
|
||||
void validate_axis(const std::vector<double>& axis,const char* why){
|
||||
require_calibration(axis.size()>=2&&axis.size()<=256,why);
|
||||
for(std::size_t i=0;i<axis.size();++i)require_calibration(std::isfinite(axis[i])&&axis[i]>=0&&(!i||axis[i]>axis[i-1]),why);
|
||||
}
|
||||
struct TableInterval {std::size_t lower;double fraction;};
|
||||
TableInterval interval(const std::vector<double>& axis,double value){
|
||||
const auto high=std::upper_bound(axis.begin(),axis.end(),value);
|
||||
const auto lower=high==axis.begin()?0:high==axis.end()?axis.size()-2:std::size_t(high-axis.begin()-1);
|
||||
return {lower,std::clamp((value-axis[lower])/(axis[lower+1]-axis[lower]),0.,1.)};
|
||||
}
|
||||
}
|
||||
SensorEcu::SensorEcu(SensorEcuConfig config):config_(std::move(config)){
|
||||
sensor_ecu_strategy_key(config_.strategy);
|
||||
require_calibration(!config_.ignition_map.empty()&&config_.ignition_map.size()<=256,"Ignition map needs 1..256 points");
|
||||
for(std::size_t i=0;i<config_.ignition_map.size();++i){const auto p=config_.ignition_map[i];require_calibration(std::isfinite(p.rpm)&&p.rpm>=0&&(!i||p.rpm>config_.ignition_map[i-1].rpm)&&std::isfinite(p.degrees_btdc)&&p.degrees_btdc>=-90&&p.degrees_btdc<=90,"Invalid ignition map calibration");}
|
||||
require_calibration(std::isfinite(config_.rev_limit_rpm)&&std::isfinite(config_.rev_resume_rpm)&&config_.rev_limit_rpm>0&&config_.rev_resume_rpm>=0&&config_.rev_resume_rpm<config_.rev_limit_rpm,"Invalid sensor ECU rev limiter calibration");
|
||||
if(config_.strategy==SensorEcuConfig::Strategy::AlphaN){
|
||||
validate_axis(config_.fuel_rpm,"Fuel RPM axis must be finite, nonnegative and increasing");validate_axis(config_.fuel_tps,"Fuel TPS axis must be finite and increasing");
|
||||
require_calibration(config_.fuel_tps.front()==0&&config_.fuel_tps.back()==1,"Fuel TPS axis must span 0..1");
|
||||
require_calibration(config_.fuel_per_cycle.size()==config_.fuel_rpm.size()*config_.fuel_tps.size(),"Fuel table dimensions do not match axes");
|
||||
for(const auto mass:config_.fuel_per_cycle)require_calibration(std::isfinite(mass)&&mass>=0,"Fuel table requires finite nonnegative kg/cycle");
|
||||
}
|
||||
if(config_.strategy==SensorEcuConfig::Strategy::SpeedDensity){
|
||||
require_calibration(std::isfinite(config_.displacement)&&config_.displacement>0&&std::isfinite(config_.air_gas_constant)&&config_.air_gas_constant>0&&std::isfinite(config_.target_afr)&&config_.target_afr>0,"Speed-density displacement, air gas constant and target AFR must be positive and finite");
|
||||
validate_axis(config_.ve_rpm,"VE RPM axis must be finite, nonnegative and increasing");validate_axis(config_.ve_pressure,"VE absolute pressure axis must be finite, nonnegative and increasing");
|
||||
require_calibration(config_.volumetric_efficiency.size()==config_.ve_rpm.size()*config_.ve_pressure.size(),"VE table dimensions do not match axes");
|
||||
for(const auto ve:config_.volumetric_efficiency)require_calibration(std::isfinite(ve)&&ve>=0&&ve<=3,"VE cells must be finite fractions in 0..3");
|
||||
}
|
||||
}
|
||||
void SensorEcu::apply_calibration(SensorEcuConfig config){
|
||||
SensorEcu checked(std::move(config));if(checked.config_.strategy!=config_.strategy)throw std::invalid_argument("ECU strategy requires Build");config_=std::move(checked.config_);
|
||||
}
|
||||
const SensorEcuState& SensorEcu::sample(const SignalValue& speed,const SignalValue& angle,const SignalValue& tps,double time){
|
||||
return sample(SensorEcuMeasurements{speed,angle,tps,{},{}},time);
|
||||
}
|
||||
const SensorEcuState& SensorEcu::sample(const SensorEcuMeasurements& measured,double time){
|
||||
const auto& speed=measured.speed;const auto& angle=measured.angle;const auto& tps=measured.tps;
|
||||
state_={};const auto crank=ecu_crank_sample(speed,angle,time);
|
||||
if(!crank){clock_.reset();return state_;}
|
||||
double opening=0;
|
||||
if(config_.strategy==SensorEcuConfig::Strategy::AlphaN){
|
||||
if(tps.validity!=SignalValidity::Valid||tps.timestamp!=time||!std::holds_alternative<double>(tps.value)){clock_.reset();return state_;}
|
||||
opening=std::get<double>(tps.value);if(!std::isfinite(opening)||opening<0||opening>1){clock_.reset();return state_;}
|
||||
}
|
||||
double pressure=0,temperature=0;
|
||||
if(config_.strategy==SensorEcuConfig::Strategy::SpeedDensity){
|
||||
const auto current=[&](const SignalValue& value){return value.validity==SignalValidity::Valid&&value.timestamp==time&&std::holds_alternative<double>(value.value)&&std::isfinite(std::get<double>(value.value))&&std::get<double>(value.value)>0;};
|
||||
if(!current(measured.map)||!current(measured.iat)){clock_.reset();return state_;}
|
||||
pressure=std::get<double>(measured.map.value);temperature=std::get<double>(measured.iat.value);
|
||||
}
|
||||
const double rpm=crank->angular_speed*60/(2*double(pi));
|
||||
if(!std::isfinite(rpm)){clock_.reset();return state_;}
|
||||
double advance=config_.ignition_map.front().degrees_btdc;
|
||||
for(std::size_t i=1;i<config_.ignition_map.size();++i){const auto a=config_.ignition_map[i-1],b=config_.ignition_map[i];advance=std::lerp(double(a.degrees_btdc),double(b.degrees_btdc),std::clamp((rpm-a.rpm)/(b.rpm-a.rpm),0.,1.));if(rpm<=b.rpm)break;}
|
||||
const auto timing=clock_.sample(speed,angle,time,advance);if(!timing.valid)return state_;
|
||||
if(!config_.rev_limit_enabled)limiter_=false;
|
||||
else if(rpm>=config_.rev_limit_rpm)limiter_=true;
|
||||
else if(rpm<=config_.rev_resume_rpm)limiter_=false;
|
||||
state_.sensor_fault=false;state_.rev_limited=limiter_;state_.spark_advance=advance;
|
||||
if(limiter_)return state_;
|
||||
state_.spark_events=timing.sparks;
|
||||
if(config_.strategy==SensorEcuConfig::Strategy::AlphaN){
|
||||
const auto row=interval(config_.fuel_rpm,rpm),column=interval(config_.fuel_tps,opening);const auto width=config_.fuel_tps.size();
|
||||
const auto a=row.lower*width+column.lower,b=a+width;
|
||||
state_.fuel_per_cycle=std::lerp(std::lerp(config_.fuel_per_cycle[a],config_.fuel_per_cycle[a+1],column.fraction),std::lerp(config_.fuel_per_cycle[b],config_.fuel_per_cycle[b+1],column.fraction),row.fraction);
|
||||
}
|
||||
if(config_.strategy==SensorEcuConfig::Strategy::SpeedDensity){
|
||||
const auto row=interval(config_.ve_rpm,rpm),column=interval(config_.ve_pressure,pressure);const auto width=config_.ve_pressure.size();const auto a=row.lower*width+column.lower,b=a+width;
|
||||
state_.volumetric_efficiency=std::lerp(std::lerp(config_.volumetric_efficiency[a],config_.volumetric_efficiency[a+1],column.fraction),std::lerp(config_.volumetric_efficiency[b],config_.volumetric_efficiency[b+1],column.fraction),row.fraction);
|
||||
// Divide first so finite high P/T pairs do not overflow R*T and silently
|
||||
// become a valid zero-fuel estimate.
|
||||
const double density=pressure/config_.air_gas_constant/temperature;
|
||||
state_.estimated_air_per_cycle=density*config_.displacement*state_.volumetric_efficiency;
|
||||
state_.fuel_per_cycle=state_.estimated_air_per_cycle/config_.target_afr;
|
||||
}
|
||||
if(config_.strategy!=SensorEcuConfig::Strategy::Ignition){state_.fuel_mass_flow=state_.fuel_per_cycle*rpm/120;if(!std::isfinite(state_.fuel_mass_flow)||!std::isfinite(state_.fuel_per_cycle)){state_={};clock_.reset();}}
|
||||
return state_;
|
||||
}
|
||||
} // namespace fluidsim::port
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
void Crankshaft::add_torque(float torque) {
|
||||
external_torque_ += torque;
|
||||
}
|
||||
float Crankshaft::take_torque(){const float torque=external_torque_;external_torque_=0;return torque;}
|
||||
|
||||
void Crankshaft::step(float dt) {
|
||||
if (!std::isfinite(omega)) omega = 0.0f;
|
||||
@@ -24,6 +26,11 @@ void Crankshaft::step(float dt) {
|
||||
// Brake shaft output, before external test load/drivetrain reaction. Using
|
||||
// acceleration torque reports zero dyno power at a loaded steady speed.
|
||||
const float instant_power = net_torque * omega;
|
||||
record_output(net_torque,instant_power);
|
||||
external_torque_ = 0.0f;
|
||||
drivetrain_torque = 0.0f;
|
||||
}
|
||||
void Crankshaft::record_output(float net_torque,float instant_power){
|
||||
if (power_count_ == power_buffer_.size()) power_sum_ -= power_buffer_[power_index_];
|
||||
else ++power_count_;
|
||||
power_buffer_[power_index_] = instant_power;
|
||||
@@ -37,8 +44,6 @@ void Crankshaft::step(float dt) {
|
||||
torque_sum_ += net_torque;
|
||||
torque_index_ = (torque_index_ + 1) % torque_buffer_.size();
|
||||
average_torque = torque_sum_ / static_cast<float>(torque_count_);
|
||||
external_torque_ = 0.0f;
|
||||
drivetrain_torque = 0.0f;
|
||||
}
|
||||
|
||||
float EngineCylinder::swept_volume() const {
|
||||
@@ -80,6 +85,7 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
|
||||
air_mass = 1.225f * volume;
|
||||
exhaust_mass = 0.0f;
|
||||
fuel_mass = 0.0f;
|
||||
fuel_equivalent_mass=0;
|
||||
energy = 101325.0f * volume / (gamma - 1.0f);
|
||||
pressure = 101325.0f;
|
||||
intake.owner_energy = exhaust.owner_energy = &energy;
|
||||
@@ -146,8 +152,11 @@ float FourStrokeCylinder::advance_valve(ValveState& state,const CamProfile& cam,
|
||||
}
|
||||
|
||||
void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
|
||||
if(ignition_command&&!transported_fuel)throw std::invalid_argument("External ignition requires transported fuel delivery");
|
||||
const float rpm = crank.omega * 60.0f / (2.0f * pi);
|
||||
if (ecu) {
|
||||
if (ignition_command) {
|
||||
rev_limit_active=false;
|
||||
} else if (ecu) {
|
||||
spark_advance = ecu->spark_advance();
|
||||
rev_limit_active = ecu->rev_limited();
|
||||
} else {
|
||||
@@ -175,16 +184,23 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
|
||||
const float current_degrees = wrap_degrees(theta * 180.0f / pi);
|
||||
const bool intake_closed_this_step=intake_cam_ && sample_cam(*intake_cam_,previous_degrees).lift_metres>0 && sample_cam(*intake_cam_,current_degrees).lift_metres<=0;
|
||||
if (intake_closed_this_step) {
|
||||
if (!transported_fuel)
|
||||
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
|
||||
external_charge_ready_=true;
|
||||
if (!transported_fuel){const float injected=ecu?ecu->fuel_for_air(air_mass):air_mass/std::max(afr,1e-6f);fuel_equivalent_mass=std::max(0.f,fuel_equivalent_mass+injected-fuel_mass);fuel_mass=injected;}
|
||||
const float charge_afr = air_mass / std::max(fuel_mass, 1e-12f);
|
||||
fuel_injected = fuel_mass > 0.0f && (!transported_fuel || (charge_afr >= 7 && charge_afr <= 24));
|
||||
}
|
||||
|
||||
const float spark_angle = wrap_degrees(720.0f - spark_advance);
|
||||
const bool crossed_spark = crossed_angle(previous_degrees,current_degrees,spark_angle);
|
||||
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->ignition_cut() : rev_limit_active) && random_unit() >= misfire_probability) {
|
||||
const bool crossed_spark = ignition_command?ignition_command->fire:crossed_angle(previous_degrees,current_degrees,spark_angle);
|
||||
const bool ignition_cut=ignition_command?ignition_command->cut:ecu?ecu->ignition_cut():rev_limit_active;
|
||||
if(ignition_command)ignition_command->fire=false;
|
||||
if(ignition_command&&combustion_active){float travel=current_degrees-previous_degrees;if(travel<0)travel+=720;external_burn_angle_+=travel;}
|
||||
// Wasted sparks on the exhaust revolution must not overwrite the most
|
||||
// recent trapped-charge measurement with already burned exhaust gas.
|
||||
if(crossed_spark&&(!ignition_command||external_charge_ready_||fuel_injected)){charge_sampled=true;charge_afr=fuel_mass>1e-12f?air_mass/fuel_mass:std::numeric_limits<float>::quiet_NaN();external_charge_ready_=false;}
|
||||
if (crossed_spark && fuel_injected && !combustion_active && !ignition_cut && random_unit() >= misfire_probability) {
|
||||
combustion_active = true;
|
||||
external_burn_angle_=0;
|
||||
burn_fraction = 0.0f;
|
||||
active_fuel_mass_ = transported_fuel ? std::min(fuel_mass, air_mass / 14.7f) : fuel_mass;
|
||||
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
|
||||
@@ -193,10 +209,11 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
|
||||
if (combustion_active) {
|
||||
float angle_since_spark = current_degrees - spark_angle;
|
||||
if (angle_since_spark < 0.0f) angle_since_spark += 720.0f;
|
||||
if(ignition_command)angle_since_spark=external_burn_angle_;
|
||||
const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f);
|
||||
const float burned = phi <= 0.0f ? 0.0f : 1.0f - std::exp(-wiebe_a * std::pow(phi, wiebe_m + 1.0f));
|
||||
float fraction = std::max(burn_fraction, std::clamp(burned, 0.0f, 1.0f));
|
||||
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) {
|
||||
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + (ignition_command?0:spark_advance)) {
|
||||
fraction = 1.0f;
|
||||
combustion_active = false;
|
||||
if (!transported_fuel) {
|
||||
@@ -221,6 +238,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
|
||||
}
|
||||
|
||||
if (!ecu && rev_limit_active) {
|
||||
fuel_equivalent_mass=std::max(0.f,fuel_equivalent_mass-fuel_mass);
|
||||
fuel_mass = 0.0f;
|
||||
fuel_injected = false;
|
||||
combustion_active = false;
|
||||
@@ -232,6 +250,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
|
||||
|
||||
void EngineCylinder::update_state(float dt) {
|
||||
float delta_air = 0.0f, delta_exhaust = 0.0f, delta_fuel = 0.0f, delta_energy = 0.0f;
|
||||
float delta_equivalent=0;const float equivalent=std::clamp(std::max(fuel_mass,fuel_equivalent_mass)/std::max(total_mass(),1e-12f),0.f,1.f);
|
||||
for (Port* port : {&intake, &exhaust}) {
|
||||
const float mass_flow = port->mass_flow;
|
||||
const float fraction = mass_flow >= 0.0f ? port->air_fraction : air_fraction();
|
||||
@@ -241,11 +260,14 @@ void EngineCylinder::update_state(float dt) {
|
||||
delta_fuel += mass_flow * fuel_fraction * dt;
|
||||
delta_exhaust += mass_flow * std::max(1.0f - fraction - fuel_fraction, 0.0f) * dt;
|
||||
delta_energy += mass_flow * port->enthalpy * dt;
|
||||
delta_equivalent+=mass_flow*(mass_flow>=0?std::max(port->fuel_equivalent_fraction,port->fuel_fraction):equivalent)*dt;
|
||||
}
|
||||
air_mass += delta_air;
|
||||
exhaust_mass += delta_exhaust;
|
||||
fuel_mass += delta_fuel;
|
||||
energy += delta_energy;
|
||||
fuel_equivalent_mass=std::clamp(fuel_equivalent_mass+delta_equivalent,0.f,std::max(total_mass(),0.f));
|
||||
if(fuel_equivalent_mass<std::numeric_limits<float>::min())fuel_equivalent_mass=0;
|
||||
|
||||
const float current_volume = std::max(volume, 1e-12f);
|
||||
float current_pressure = (gamma - 1.0f) * energy / current_volume;
|
||||
@@ -263,6 +285,7 @@ void EngineCylinder::update_state(float dt) {
|
||||
air_mass = 1e-9f;
|
||||
exhaust_mass = 0.0f;
|
||||
fuel_mass = 0.0f;
|
||||
fuel_equivalent_mass=0;
|
||||
energy = 101325.0f * current_volume / (gamma - 1.0f);
|
||||
} else if (energy < 0.0f) {
|
||||
energy = 101325.0f * current_volume / (gamma - 1.0f);
|
||||
@@ -314,6 +337,7 @@ void EngineCylinder::sync_ports() {
|
||||
port->temperature = temp;
|
||||
port->air_fraction = fraction;
|
||||
port->fuel_fraction = fuel_fraction;
|
||||
port->fuel_equivalent_fraction=std::clamp(std::max(fuel_mass,fuel_equivalent_mass)/std::max(total_mass(),1e-12f),0.f,1.f);
|
||||
port->enthalpy = enthalpy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,8 @@ void read_ecu(Reader r,EcuConfig& c) {
|
||||
r.field("power_afr",c.power_afr);
|
||||
r.field("rev_limit_enabled",c.rev_limit_enabled);
|
||||
r.field("rev_limit_fuel_cut_enabled",c.rev_limit_fuel_cut_enabled);
|
||||
r.field("rev_limit_ignition_cut_enabled",c.rev_limit_ignition_cut_enabled);
|
||||
r.field("rev_limit_cut_time_s",c.rev_limit_cut_time);
|
||||
r.field("rev_limit_rpm",c.rev_limit_rpm);
|
||||
r.field("rev_resume_rpm",c.rev_resume_rpm);
|
||||
r.field("stoichiometric_afr",c.stoichiometric_afr);
|
||||
@@ -377,6 +379,7 @@ void validate_ecu(const EcuConfig& c,const std::string& path) {
|
||||
require(c.throttle_time_constant>0 && c.throttle_rate>0 && c.idle_spark_gain>=0 &&
|
||||
c.throttle_closing_time_constant>=0 && c.throttle_closing_rate>=0,path,"ecu: invalid actuator calibration");
|
||||
require(c.stoichiometric_afr>0 && c.power_afr>0,path,"ecu: AFR targets must be positive");
|
||||
require(c.rev_limit_cut_time>=0&&c.rev_limit_cut_time<=10,path,"ecu: rev_limit_cut_time_s must be in 0..10");
|
||||
}
|
||||
void validate_vehicle(const VehicleConfig& c,const std::string& path) {
|
||||
require(c.mass>0 && c.wheel_radius>0 && c.rotational_inertia>=0 &&
|
||||
|
||||
45
src/fuel_injector.cpp
Normal file
45
src/fuel_injector.cpp
Normal file
@@ -0,0 +1,45 @@
|
||||
#include "FluidSim/FuelInjector.hpp"
|
||||
#include "FluidSim/Fluid.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
FuelInjector::FuelInjector(FuelInjectorConfig config):config_(config){
|
||||
if((config.supply!=FuelInjectorConfig::Supply::IdealRail&&config.supply!=FuelInjectorConfig::Supply::FiniteRail)||
|
||||
!std::isfinite(config.maximum_mass_flow)||config.maximum_mass_flow<=0||
|
||||
!std::isfinite(config.initial_fuel_mass)||config.initial_fuel_mass<0||
|
||||
!std::isfinite(config.fuel_temperature)||config.fuel_temperature<=0)
|
||||
throw std::invalid_argument("Invalid injector rail, flow capacity or fuel temperature");
|
||||
state_.remaining_mass=config.supply==FuelInjectorConfig::Supply::FiniteRail?config.initial_fuel_mass:0;
|
||||
state_.empty=config.supply==FuelInjectorConfig::Supply::FiniteRail&&state_.remaining_mass==0;
|
||||
}
|
||||
const FuelInjectorState& FuelInjector::deliver(Volume0D& host,const SignalValue& command,double time,double dt){
|
||||
if(!std::isfinite(time)||!std::isfinite(dt)||dt<=0)throw std::invalid_argument("Injector requires finite time and positive period");
|
||||
state_.mass_flow=0;state_.saturated=false;state_.command_fault=false;
|
||||
if(command.validity!=SignalValidity::Valid||command.timestamp!=time||!std::holds_alternative<double>(command.value)){
|
||||
state_.command_fault=true;return state_;
|
||||
}
|
||||
const auto flow=std::get<double>(command.value);
|
||||
if(!std::isfinite(flow)||flow<0){state_.command_fault=true;return state_;}
|
||||
state_.saturated=flow>config_.maximum_mass_flow;
|
||||
auto requested=std::min(flow,config_.maximum_mass_flow)*dt;
|
||||
if(config_.supply==FuelInjectorConfig::Supply::FiniteRail)requested=std::min(requested,state_.remaining_mass);
|
||||
if(!std::isfinite(requested)||requested>std::numeric_limits<float>::max()){state_.command_fault=true;return state_;}
|
||||
float mass=static_cast<float>(requested);
|
||||
// Never remove more fuel from a finite supply than it actually contains.
|
||||
if(double(mass)>requested)mass=std::nextafter(mass,0.f);
|
||||
const double energy=double(mass)*host.gas_constant/(host.gamma-1)*config_.fuel_temperature;
|
||||
if(!std::isfinite(energy)||double(host.internal_energy())+energy>std::numeric_limits<float>::max()||double(host.mass())+mass>std::numeric_limits<float>::max()){
|
||||
state_.command_fault=true;return state_;
|
||||
}
|
||||
host.add_fuel_mass(mass,config_.fuel_temperature);
|
||||
state_.mass_flow=double(mass)/dt;state_.delivered_mass+=mass;
|
||||
if(config_.supply==FuelInjectorConfig::Supply::FiniteRail){
|
||||
state_.remaining_mass=std::max(0.,state_.remaining_mass-mass);
|
||||
state_.empty=state_.remaining_mass<std::numeric_limits<float>::denorm_min();
|
||||
}
|
||||
return state_;
|
||||
}
|
||||
}
|
||||
25
src/logic_assembly.cpp
Normal file
25
src/logic_assembly.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#include "logic_assembly.hpp"
|
||||
#include <algorithm>
|
||||
|
||||
namespace fluidsim::port {
|
||||
void extract_machine_logic(const MachineDocument& source,const std::set<std::uint32_t>& selected,MachineDocument& assembly){
|
||||
std::set<std::uint32_t> chosen;std::unordered_map<std::uint32_t,std::vector<std::uint32_t>> neighbours;
|
||||
for(const auto& node:source.logic.nodes)if(((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu)&&selected.contains(node.part))||(node.kind==LogicNodeKind::Sensor&&assembly.find_sensor(node.sensor)))chosen.insert(node.id);
|
||||
for(const auto& wire:source.logic.links){neighbours[wire.from.node].push_back(wire.to.node);neighbours[wire.to.node].push_back(wire.from.node);}
|
||||
std::vector<std::uint32_t> pending(chosen.begin(),chosen.end());for(std::size_t i=0;i<pending.size();++i)for(const auto id:neighbours[pending[i]])if(chosen.insert(id).second)pending.push_back(id);
|
||||
for(auto node:source.logic.nodes)if(chosen.contains(node.id)){
|
||||
if((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu)&&!selected.contains(node.part)){if(const auto host=source.find(node.part))node.binding_label=host->name;node.part=0;}
|
||||
if(node.kind==LogicNodeKind::Sensor){if(const auto sensor=source.find_sensor(node.sensor)){
|
||||
if(!assembly.find_sensor(sensor->id)){auto copy=*sensor;for(auto* tap:{©.target,©.reference})if(!selected.contains(tap->part)){if(const auto host=source.find(tap->part))tap->label=host->name;tap->part=0;}assembly.next_sensor_id=std::max(assembly.next_sensor_id,copy.id+1);assembly.sensors.push_back(std::move(copy));}
|
||||
}else node.sensor=0;
|
||||
}
|
||||
assembly.logic.next_id=std::max(assembly.logic.next_id,node.id+1);assembly.logic.nodes.push_back(std::move(node));
|
||||
}
|
||||
for(const auto& wire:source.logic.links)if(chosen.contains(wire.from.node)&&chosen.contains(wire.to.node))assembly.logic.links.push_back(wire);
|
||||
}
|
||||
void insert_machine_logic(MachineDocument& destination,const MachineDocument& assembly,const std::unordered_map<std::uint32_t,std::uint32_t>& parts,const std::unordered_map<std::uint32_t,std::uint32_t>& sensors){
|
||||
std::unordered_map<std::uint32_t,std::uint32_t> nodes;float shift=0;if(!destination.logic.nodes.empty()){float right=-1e6f,left=1e6f;for(const auto& n:destination.logic.nodes)right=std::max(right,n.position.x);for(const auto& n:assembly.logic.nodes)left=std::min(left,n.position.x);if(!assembly.logic.nodes.empty())shift=right-left+320;}
|
||||
for(auto node:assembly.logic.nodes){const auto original=node.id;node.id=destination.logic.next_id++;node.position.x+=shift;if((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu))node.part=parts.contains(node.part)?parts.at(node.part):0;if(node.kind==LogicNodeKind::Sensor)node.sensor=sensors.contains(node.sensor)?sensors.at(node.sensor):0;nodes.emplace(original,node.id);destination.logic.nodes.push_back(std::move(node));}
|
||||
for(auto wire:assembly.logic.links){wire.from.node=nodes.at(wire.from.node);wire.to.node=nodes.at(wire.to.node);destination.logic.links.push_back(std::move(wire));}
|
||||
}
|
||||
}
|
||||
8
src/logic_assembly.hpp
Normal file
8
src/logic_assembly.hpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <set>
|
||||
#include <unordered_map>
|
||||
namespace fluidsim::port {
|
||||
void extract_machine_logic(const MachineDocument&,const std::set<std::uint32_t>&,MachineDocument&);
|
||||
void insert_machine_logic(MachineDocument&,const MachineDocument&,const std::unordered_map<std::uint32_t,std::uint32_t>& parts,const std::unordered_map<std::uint32_t,std::uint32_t>& sensors);
|
||||
}
|
||||
309
src/logic_editor.cpp
Normal file
309
src/logic_editor.cpp
Normal file
@@ -0,0 +1,309 @@
|
||||
#include "FluidSim/Desktop/LogicEditor.hpp"
|
||||
#include "FluidSim/Desktop/PortAppearance.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cctype>
|
||||
#include <deque>
|
||||
#include <iomanip>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
|
||||
namespace fluidsim::port::desktop {
|
||||
namespace {
|
||||
const sf::Color background{18,21,26},panel{27,31,38},border{54,62,75},text_color{229,233,240},muted{158,168,183},accent{116,170,235},amber{234,187,106};
|
||||
constexpr float node_width=240,header_height=34,pins_top=60,pin_height=40;
|
||||
void box(sf::RenderTarget& target,sf::FloatRect r,sf::Color fill,sf::Color outline=sf::Color::Transparent){sf::RectangleShape shape(r.size);shape.setPosition(r.position);shape.setFillColor(fill);shape.setOutlineColor(outline);shape.setOutlineThickness(1);target.draw(shape);}
|
||||
void line(sf::RenderTarget& target,sf::Vector2f a,sf::Vector2f b,sf::Color color){const sf::Vertex vertices[]{{a,color},{b,color}};target.draw(vertices,2,sf::PrimitiveType::Lines);}
|
||||
void circle(sf::RenderTarget& target,sf::Vector2f p,float radius,sf::Color fill,sf::Color outline){sf::CircleShape shape(radius);shape.setOrigin({radius,radius});shape.setPosition(p);shape.setFillColor(fill);shape.setOutlineColor(outline);shape.setOutlineThickness(1.5f);target.draw(shape);}
|
||||
std::string fitted(const sf::Font* font,std::string s,float width,unsigned size){if(!font)return {};sf::Text text(*font,s,size);if(text.getLocalBounds().size.x<=width)return s;while(!s.empty()){s.pop_back();text.setString(s+"...");if(text.getLocalBounds().size.x<=width)return s+"...";}return {};}
|
||||
void label(sf::RenderTarget& target,const sf::Font* font,const std::string& s,float x,float y,float width,unsigned size=12,sf::Color color=text_color){if(!font||width<=0)return;sf::Text text(*font,fitted(font,s,width,size),size);text.setPosition({x,y});text.setFillColor(color);target.draw(text);}
|
||||
void wrapped(sf::RenderTarget& target,const sf::Font* font,std::string text,float x,float y,float width,unsigned size,int rows,sf::Color color){if(!font)return;for(int row=0;row<rows&&!text.empty();++row){std::size_t count=text.size();sf::Text measured(*font,text,size);while(count&&measured.getLocalBounds().size.x>width){--count;measured.setString(text.substr(0,count));}if(!count)return;if(count<text.size()){const auto space=text.rfind(' ',count);if(space!=std::string::npos&&space>0)count=space;}label(target,font,text.substr(0,count)+(row==rows-1&&count<text.size()?"...":""),x,y+row*(size+4),width,size,color);text.erase(0,count);while(!text.empty()&&text.front()==' ')text.erase(text.begin());}}
|
||||
std::string number(double value,int precision=4){std::ostringstream out;out<<std::setprecision(precision)<<value;return out.str();}
|
||||
std::string reading(const SignalValue* value,const MachinePortDescriptor& port){if(!value)return "--";if(value->validity!=SignalValidity::Valid)return value->validity==SignalValidity::Fault?"FAULT":"MISSING";if(std::holds_alternative<bool>(value->value))return std::get<bool>(value->value)?"true":"false";return number(std::get<double>(value->value))+" "+signal_quantity_unit(port.quantity);}
|
||||
int category(LogicNodeKind k){if(k<=LogicNodeKind::NumberInput)return 1;if(k<=LogicNodeKind::Hysteresis)return 2;if(k<=LogicNodeKind::BooleanToNumber)return 3;if(logic_memory_node(k))return 5;return 4;}
|
||||
bool manual_boolean(LogicNodeKind k){return k==LogicNodeKind::Switch||k==LogicNodeKind::Button;}
|
||||
bool manual_number(LogicNodeKind k){return k==LogicNodeKind::Slider||k==LogicNodeKind::NumberInput;}
|
||||
bool has_control(LogicNodeKind k){return manual_boolean(k)||manual_number(k);}
|
||||
bool configurable_quantity(LogicNodeKind k){return k==LogicNodeKind::NumberConstant||manual_number(k)||(k>=LogicNodeKind::Greater&&k<=LogicNodeKind::BooleanToNumber)||k==LogicNodeKind::Readout||k==LogicNodeKind::NumberDelay;}
|
||||
std::uint32_t physical_host(const MachineDocument& doc,const LogicNode& n){if(n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu)return n.part;if(n.kind==LogicNodeKind::Sensor)if(const auto sensor=doc.find_sensor(n.sensor))return sensor->target.part;return 0;}
|
||||
const LogicNodeState* live_node(const LogicEditorHooks& hooks,std::uint32_t id){if(hooks.live)for(const auto& state:hooks.live->logic)if(state.id==id)return &state;return nullptr;}
|
||||
const SignalValue* live_value(const LogicEditorHooks& hooks,const LogicNode& n,const std::string& key){const auto state=live_node(hooks,n.id);if(!state)return nullptr;const auto ports=logic_node_ports(n);for(std::size_t i=0;i<ports.size()&&i<state->values.size();++i)if(ports[i].key==key)return &state->values[i];return nullptr;}
|
||||
float body_height(const LogicNode& n){unsigned inputs=0,outputs=0;for(const auto& p:logic_node_ports(n)){if(p.direction==PortDirection::Input)++inputs;else ++outputs;}return pins_top+pin_height*std::max(inputs,outputs)+12+(has_control(n.kind)||n.kind==LogicNodeKind::Readout?40:0);}
|
||||
struct Field {
|
||||
std::string key,label;
|
||||
double* value{};bool* boolean{};unsigned* integer{};
|
||||
double step{1},minimum{-1e15},maximum{1e15};bool restart{};
|
||||
double get()const{return value?*value:boolean?double(*boolean):double(*integer);}
|
||||
void set(double v)const{if(value)*value=v;else if(boolean)*boolean=v>=.5;else *integer=unsigned(std::round(v));}
|
||||
};
|
||||
std::vector<Field> fields(LogicNode& n){
|
||||
std::vector<Field> result;
|
||||
const auto num=[&](const char* key,const std::string& name,double& v,double step=1,bool restart=false,double minimum=-1e15,double maximum=1e15){result.push_back({key,name,&v,nullptr,nullptr,step,minimum,maximum,restart});};
|
||||
const auto bit=[&](const char* key,const char* name,bool& v,bool restart=false){result.push_back({key,name,nullptr,&v,nullptr,1,0,1,restart});};
|
||||
const auto unit=std::string(signal_quantity_unit(n.quantity));
|
||||
if(n.kind==LogicNodeKind::BooleanConstant)bit("boolean","Value",n.boolean);
|
||||
if(n.kind==LogicNodeKind::Switch||n.kind==LogicNodeKind::BooleanDelay)bit("boolean","Initial state",n.boolean,true);
|
||||
if(n.kind==LogicNodeKind::NumberConstant||manual_number(n.kind)||n.kind==LogicNodeKind::NumberDelay)num("value",n.kind==LogicNodeKind::NumberConstant?"Value ["+unit+"]":"Initial value ["+unit+"]",n.value,1,n.kind!=LogicNodeKind::NumberConstant);
|
||||
if(manual_number(n.kind)){num("minimum","Minimum ["+unit+"]",n.minimum,1,true);num("maximum","Maximum ["+unit+"]",n.maximum,1,true);}
|
||||
if(n.kind==LogicNodeKind::And||n.kind==LogicNodeKind::Or||n.kind==LogicNodeKind::Xor||n.kind==LogicNodeKind::Nand||n.kind==LogicNodeKind::Nor)result.push_back({"input_count","Number of inputs",nullptr,nullptr,&n.input_count,1,2,16,true});
|
||||
if(n.kind==LogicNodeKind::Window||n.kind==LogicNodeKind::Hysteresis||n.kind==LogicNodeKind::Clamp){num("low","Low ["+unit+"]",n.low);num("high","High ["+unit+"]",n.high);}
|
||||
if(n.kind==LogicNodeKind::Hysteresis){bit("below","Activate below low",n.below);bit("initial","Initial output",n.initial,true);}
|
||||
if(n.kind==LogicNodeKind::Equal)num("tolerance","Tolerance ["+unit+"]",n.tolerance,.001,false,0);
|
||||
if(n.kind==LogicNodeKind::GainOffset){num("gain","Gain [scalar]",n.gain,.1);num("offset","Offset ["+unit+"]",n.offset);}
|
||||
if(n.kind==LogicNodeKind::MapRange){num("input_low","Input low ["+unit+"]",n.input_low);num("input_high","Input high ["+unit+"]",n.input_high);const auto out=std::string(signal_quantity_unit(n.output_quantity));num("output_low","Output low ["+out+"]",n.output_low);num("output_high","Output high ["+out+"]",n.output_high);bit("clamp","Clamp mapped range",n.clamp);}
|
||||
if(n.kind==LogicNodeKind::BooleanToNumber){num("false_value","False value ["+unit+"]",n.false_value);num("true_value","True value ["+unit+"]",n.true_value);}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
struct LogicEditor::Impl {
|
||||
LogicPosition center{};float zoom{1};bool needs_fit{true};
|
||||
std::uint32_t selected{};std::set<std::uint32_t> selection;
|
||||
std::optional<LogicNodeKind> placing;std::optional<LogicEndpoint> pending;bool rewiring{},port_drag{};
|
||||
sf::Vector2f mouse{},press{},previous{},drag_offset{};bool panning{},dragging{},boxing{},slider_drag{};std::uint32_t held_button{},slider_node{};double slider_preview{};
|
||||
int library_tab{},library_category{},library_scroll{},field_scroll{};
|
||||
std::string search,edit_key,buffer;bool search_focus{},search_replace{},replace_text{true};
|
||||
std::optional<LogicGraph> clipboard;std::uint64_t epoch{},clipboard_epoch{};
|
||||
std::map<std::uint32_t,std::deque<std::pair<double,double>>> traces;
|
||||
std::map<std::uint32_t,std::variant<bool,double>> manual_values;
|
||||
struct Action {sf::FloatRect rect;std::string title;std::function<void()> run;bool enabled{true},active{};};
|
||||
sf::Vector2f project(LogicPosition p,sf::FloatRect canvas)const{return canvas.position+canvas.size*.5f+sf::Vector2f{p.x-center.x,p.y-center.y}*zoom;}
|
||||
LogicPosition unproject(sf::Vector2f p,sf::FloatRect canvas)const{const auto q=(p-canvas.position-canvas.size*.5f)/zoom;return {center.x+q.x,center.y+q.y};}
|
||||
sf::FloatRect rect(const LogicNode& n,sf::FloatRect canvas)const{auto at=project(n.position,canvas);if(dragging&&selection.contains(n.id))at+=drag_offset;return {at,{node_width*zoom,body_height(n)*zoom}};}
|
||||
sf::Vector2f pin(const LogicNode& n,const std::string& key,sf::FloatRect canvas)const{unsigned in=0,out=0;const auto r=rect(n,canvas);for(const auto& p:logic_node_ports(n)){const bool input=p.direction==PortDirection::Input;const auto row=input?in++:out++;if(p.key==key)return r.position+sf::Vector2f{input?0:node_width,(pins_top+row*pin_height+7)}*zoom;}throw std::invalid_argument("Unknown logic editor pin");}
|
||||
std::optional<LogicEndpoint> pick_pin(const LogicGraph& graph,sf::Vector2f point,sf::FloatRect canvas)const{for(auto it=graph.nodes.rbegin();it!=graph.nodes.rend();++it)for(const auto& p:logic_node_ports(*it))if((point-pin(*it,p.key,canvas)).length()<=std::max(7.f,7*zoom))return LogicEndpoint{it->id,p.key};return {};}
|
||||
const LogicNode* pick(const LogicGraph& graph,sf::Vector2f point,sf::FloatRect canvas)const{for(auto it=graph.nodes.rbegin();it!=graph.nodes.rend();++it)if(rect(*it,canvas).contains(point))return &*it;return nullptr;}
|
||||
void select(std::uint32_t id,const LogicEditorHooks& h,bool extend=false){if(!extend)selection.clear();if(id){if(extend&&selection.contains(id))selection.erase(id);else selection.insert(id);}selected=selection.contains(id)?id:selection.empty()?0:*selection.rbegin();field_scroll=0;edit_key.clear();if(h.select_physical)h.select_physical(selected?physical_host(h.document,*h.document.logic.find(selected)):0);}
|
||||
void fit(const LogicGraph& graph,sf::FloatRect canvas,bool only){float x=1e9f,y=1e9f,r=-1e9f,b=-1e9f;for(const auto& n:graph.nodes)if(!only||selection.contains(n.id)){x=std::min(x,n.position.x);y=std::min(y,n.position.y);r=std::max(r,n.position.x+node_width);b=std::max(b,n.position.y+body_height(n));}if(x>r){center={};zoom=1;}else{center={(x+r)*.5f,(y+b)*.5f};zoom=std::clamp(std::min((canvas.size.x-64)/std::max(1.f,r-x),(canvas.size.y-64)/std::max(1.f,b-y)),.25f,1.4f);}needs_fit=false;}
|
||||
void deactivate(const LogicEditorHooks& h){if(h.release_buttons)h.release_buttons();if(!h.live){manual_values.clear();traces.clear();}held_button=0;slider_drag=panning=dragging=boxing=port_drag=false;pending.reset();placing.reset();edit_key.clear();search_focus=false;}
|
||||
bool control(const LogicEditorHooks& h,std::uint32_t id,bool value){try{h.boolean_control(id,value);manual_values[id]=value;return true;}catch(const std::exception& e){h.status(e.what());return false;}}
|
||||
bool control(const LogicEditorHooks& h,std::uint32_t id,double value){try{h.number_control(id,value);manual_values[id]=value;return true;}catch(const std::exception& e){h.status(e.what());return false;}}
|
||||
bool connect(const LogicEditorHooks& h,const LogicEndpoint& target){if(!pending)return false;const auto source=*pending;const bool rewire=rewiring;const bool result=h.edit([&]{if(rewire)h.document.logic.disconnect(source);h.document.logic.connect(source,target);});if(result){pending.reset();port_drag=false;h.status("Signal connected. Red Boolean / green Number; Ctrl+Z undoes.");}return result;}
|
||||
PortCompatibility preview(const LogicGraph& graph,const LogicEndpoint& target)const{try{auto copy=graph;if(rewiring&&pending)copy.disconnect(*pending);if(pending)return logic_connection_preview(copy,*pending,target);return {false,"Choose a pin first"};}catch(const std::exception& e){return {false,e.what()};}}
|
||||
void configure(LogicNode& node,const MachinePortDescriptor& descriptor)const{if(descriptor.domain==PortDomain::Number){if(configurable_quantity(node.kind)){node.quantity=descriptor.quantity;node.pressure_reference=descriptor.pressure_reference;node.output_quantity=descriptor.quantity;node.output_pressure_reference=descriptor.pressure_reference;}if(node.kind==LogicNodeKind::Command){if(descriptor.quantity==SignalQuantity::AngularSpeed)node.command=LogicCommand::MotorSpeed;else if(descriptor.quantity==SignalQuantity::Torque)node.command=LogicCommand::MotorTorque;else if(descriptor.quantity==SignalQuantity::Fraction)node.command=LogicCommand::ThrottleOpening;}if(node.kind==LogicNodeKind::Sensor){if(descriptor.quantity==SignalQuantity::AngularSpeed||descriptor.quantity==SignalQuantity::Angle)node.sensor_kind=MachineSensorKind::Crank;else if(descriptor.quantity==SignalQuantity::Temperature)node.sensor_kind=MachineSensorKind::GasTemperature;else if(descriptor.quantity==SignalQuantity::MassFlow)node.sensor_kind=MachineSensorKind::MassFlow;else if(descriptor.quantity==SignalQuantity::Fraction)node.sensor_kind=MachineSensorKind::ThrottlePosition;else if(descriptor.quantity==SignalQuantity::Pressure&&descriptor.pressure_reference==PressureReference::Differential)node.sensor_kind=MachineSensorKind::DifferentialPressure;}}}
|
||||
std::vector<int> items(const LogicEditorHooks& h)const{std::vector<int> result;std::string query=search;for(auto& c:query)c=char(std::tolower(static_cast<unsigned char>(c)));const int count=library_tab?int(h.document.logic.nodes.size()):logic_node_kind_count;for(int i=0;i<count;++i){const auto kind=library_tab?h.document.logic.nodes[i].kind:LogicNodeKind(i);if(!library_tab&&library_category&&category(kind)!=library_category)continue;std::string title=library_tab?h.document.logic.nodes[i].name:logic_node_name(kind);for(auto& c:title)c=char(std::tolower(static_cast<unsigned char>(c)));if(!query.empty()&&title.find(query)==std::string::npos)continue;
|
||||
if(!library_tab&&pending){const auto source=logic_port(h.document.logic,*pending);LogicNode candidate;candidate.kind=kind;configure(candidate,source);bool compatible=false;for(const auto& pin:logic_node_ports(candidate))if(port_compatibility(source,pin).allowed)compatible=true;if(!compatible)continue;}
|
||||
result.push_back(i);
|
||||
}return result;}
|
||||
void create(const LogicEditorHooks& h,sf::FloatRect canvas){if(!placing||h.live)return;const auto kind=*placing;const auto at=unproject(mouse,canvas);std::uint32_t id=0;const auto source=pending;const bool rewire=rewiring;if(h.edit([&]{auto& graph=h.document.logic;id=graph.add(kind,at).id;if(source)configure(*graph.find(id),logic_port(graph,*source));if(source){const auto descriptor=logic_port(graph,*source);for(const auto& pin:logic_node_ports(*graph.find(id)))if(port_compatibility(descriptor,pin).allowed){if(rewire)graph.disconnect(*source);graph.connect(*source,{id,pin.key});break;}}})){select(id,h);pending.reset();placing.reset();}}
|
||||
void copy(const LogicEditorHooks& h){if(selection.empty())return;LogicGraph fragment;for(auto n:h.document.logic.nodes)if(selection.contains(n.id)){if(n.kind==LogicNodeKind::Sensor){if(const auto s=h.document.find_sensor(n.sensor))n.binding_label=s->name;}else if(n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu){if(const auto p=h.document.find(n.part))n.binding_label=p->name;}fragment.nodes.push_back(n);fragment.next_id=std::max(fragment.next_id,n.id+1);}for(const auto& l:h.document.logic.links)if(selection.contains(l.from.node)&&selection.contains(l.to.node))fragment.links.push_back(l);clipboard=std::move(fragment);clipboard_epoch=epoch;h.status("Copied logic selection with internal wires. Commands paste with unresolved ownership.");}
|
||||
void paste(const LogicEditorHooks& h){
|
||||
if(!clipboard||h.live)return;
|
||||
std::vector<std::uint32_t> ids;const auto fragment=*clipboard;const bool same=clipboard_epoch==epoch;
|
||||
if(h.edit([&]{
|
||||
std::map<std::uint32_t,std::uint32_t> map;
|
||||
for(auto n:fragment.nodes){const auto old=n.id;n.id=h.document.logic.next_id++;n.position.x+=32;n.position.y+=32;
|
||||
if(n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu){if(same)if(const auto p=h.document.find(n.part))n.binding_label=p->name;n.part=0;}
|
||||
if(n.kind==LogicNodeKind::Sensor&&!same)n.sensor=0;
|
||||
map[old]=n.id;ids.push_back(n.id);h.document.logic.nodes.push_back(std::move(n));
|
||||
}
|
||||
for(auto l:fragment.links){l.from.node=map.at(l.from.node);l.to.node=map.at(l.to.node);h.document.logic.links.push_back(std::move(l));}
|
||||
})){selection=std::set<std::uint32_t>(ids.begin(),ids.end());selected=ids.empty()?0:ids.back();}
|
||||
}
|
||||
void remove(const LogicEditorHooks& h){if(h.live||selection.empty())return;const auto ids=selection;if(h.edit([&]{for(const auto id:ids)h.document.logic.remove(id);})){selection.clear();selected=0;pending.reset();}}
|
||||
std::vector<Action> actions(const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect,bool);
|
||||
bool commit(const LogicEditorHooks&);
|
||||
bool event(const sf::Event&,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect,bool);
|
||||
void draw(sf::RenderWindow&,const sf::Font*,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect,bool);
|
||||
};
|
||||
std::vector<LogicEditor::Impl::Action> LogicEditor::Impl::actions(const LogicEditorHooks& h,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){
|
||||
std::vector<Action> result;if(!panels)return result;
|
||||
const auto button=[&](sf::FloatRect rect,std::string title,std::function<void()> action,bool enabled=true,bool active=false){result.push_back({rect,std::move(title),std::move(action),enabled,active});};
|
||||
if(ui.left.size.x>40){const float half=(ui.left.size.x-32)/2;
|
||||
button({{20,108},{half,28}},"Nodes",[this]{library_tab=0;library_scroll=0;},true,!library_tab);button({{28+half,108},{half,28}},"Objects",[this]{library_tab=1;library_scroll=0;},true,library_tab);
|
||||
if(!library_tab){const char* names[]{"All","Ctl","Bool","Num","I/O","Mem"};const float width=(ui.left.size.x-44)/6;for(int i=0;i<6;++i)button({{20+i*(width+4),192},{width,28}},names[i],[this,i]{library_category=i;library_scroll=0;},true,library_category==i);}
|
||||
const auto entries=items(h);const int rows=ui.library_rows();library_scroll=std::clamp(library_scroll,0,std::max(0,int(entries.size())-rows));
|
||||
for(int row=0;row<rows&&row+library_scroll<int(entries.size());++row){const auto index=entries[row+library_scroll];const auto kind=library_tab?h.document.logic.nodes[index].kind:LogicNodeKind(index);const auto id=library_tab?h.document.logic.nodes[index].id:0;
|
||||
button({{20,ui.library.position.y+row*36},{ui.left.size.x-24,30}},library_tab?h.document.logic.nodes[index].name:logic_node_name(kind),[this,&h,kind,id]{search_focus=false;if(id)select(id,h);else{placing=kind;h.status("Click the Logic canvas to place "+std::string(logic_node_name(kind))+". Escape cancels.");}},library_tab||!h.live,library_tab?selection.contains(id):placing&&*placing==kind);
|
||||
}
|
||||
button({{20,ui.height-126},{ui.left.size.x-24,28}},"Fit graph [Home]",[this,&h,canvas]{fit(h.document.logic,canvas,false);});
|
||||
button({{20,ui.height-90},{ui.left.size.x-24,28}},"Compressor logic bench",[&h]{if(h.example)h.example();},!h.live);
|
||||
}
|
||||
if(ui.right.size.x<=40)return result;
|
||||
const auto n=h.document.logic.find(selected);if(!n)return result;const float x=ui.inspector_x+12,width=ui.inspector_width-24;
|
||||
button({{x,136},{width,30}},edit_key=="name"?buffer+"|":n->name,[this,n]{edit_key="name";buffer=n->name;replace_text=true;});
|
||||
if(n->kind==LogicNodeKind::Sensor){const auto sensor=h.document.find_sensor(n->sensor);button({{x,194},{width,28}},sensor?"Sensor: "+sensor->name:"Choose sensor attachment",[this,&h]{const auto id=selected;h.edit([&]{auto& n=*h.document.logic.find(id);std::vector<std::uint32_t> ids{0};for(const auto& s:h.document.sensors)ids.push_back(s.id);auto at=std::find(ids.begin(),ids.end(),n.sensor);n.sensor=at==ids.end()||++at==ids.end()?0:*at;if(const auto sensor=h.document.find_sensor(n.sensor)){n.sensor_kind=sensor->kind;n.binding_label=sensor->name;}});},!h.live);
|
||||
}else if(n->kind==LogicNodeKind::Command||n->kind==LogicNodeKind::Ecu){
|
||||
if(n->kind==LogicNodeKind::Ecu)button({{x,194},{width,28}},std::string("Preset: ")+sensor_ecu_strategy_name(n->ecu_strategy),[this,&h]{h.edit([&]{auto& n=*h.document.logic.find(selected);n.ecu_strategy=next_sensor_ecu_strategy(n.ecu_strategy);std::erase_if(h.document.logic.links,[&](const auto& wire){return wire.to.node==n.id&&(wire.to.port=="tps"||wire.to.port=="map"||wire.to.port=="iat");});});},!h.live);
|
||||
if(n->kind==LogicNodeKind::Command)button({{x,194},{width,28}},std::string("Command: ")+logic_command_name(n->command),[this,&h]{h.edit([&]{auto& n=*h.document.logic.find(selected);n.command=LogicCommand((int(n.command)+1)%logic_command_count);});},!h.live);
|
||||
const auto target=h.document.find(n->part);button({{x,228},{width,28}},target?"Target: "+target->name:"Choose physical target",[this,&h]{h.edit([&]{auto& n=*h.document.logic.find(selected);std::vector<std::uint32_t> ids{0};for(const auto& p:h.document.parts){auto candidate=n;candidate.part=p.id;if(logic_binding_problem(h.document,candidate).empty())ids.push_back(p.id);}auto at=std::find(ids.begin(),ids.end(),n.part);n.part=at==ids.end()||++at==ids.end()?0:*at;if(const auto target=h.document.find(n.part))n.binding_label=target->name;});},!h.live);
|
||||
}else if(configurable_quantity(n->kind)){
|
||||
const auto quantity=[this,&h](bool output){h.edit([&]{auto& n=*h.document.logic.find(selected);auto& q=output?n.output_quantity:n.quantity;auto& ref=output?n.output_pressure_reference:n.pressure_reference;q=SignalQuantity(int(q)%int(SignalQuantity::Inertia)+1);ref=q==SignalQuantity::Pressure?PressureReference::Absolute:PressureReference::None;});};
|
||||
button({{x,194},{width,28}},std::string("Quantity: ")+signal_quantity_key(n->quantity)+" ["+signal_quantity_unit(n->quantity)+"]",[quantity]{quantity(false);},!h.live);
|
||||
const auto pressure=[this,&h](bool output){h.edit([&]{auto& n=*h.document.logic.find(selected);auto& ref=output?n.output_pressure_reference:n.pressure_reference;ref=PressureReference(int(ref)%3+1);});};
|
||||
const char* references[]{"none","absolute","gauge","differential"};
|
||||
if(n->quantity==SignalQuantity::Pressure)button({{x,228},{width,28}},std::string("Pressure: ")+references[int(n->pressure_reference)],[pressure]{pressure(false);},!h.live);
|
||||
if(n->kind==LogicNodeKind::MapRange){const float half=(width-8)/2;button({{x,264},{half,28}},std::string("Out: ")+signal_quantity_key(n->output_quantity),[quantity]{quantity(true);},!h.live);if(n->output_quantity==SignalQuantity::Pressure)button({{x+half+8,264},{half,28}},references[int(n->output_pressure_reference)],[pressure]{pressure(true);},!h.live);}
|
||||
}
|
||||
const auto f=fields(*h.document.logic.find(selected));const int rows=std::max(1,int((ui.height-190-310)/52));field_scroll=std::clamp(field_scroll,0,std::max(0,int(f.size())-rows));
|
||||
for(int row=0;row<rows&&row+field_scroll<int(f.size());++row){const auto field=f[row+field_scroll];const float y=310+row*52.f;const bool enabled=!h.live||!field.restart;
|
||||
button({{x,y+20},{width-68,26}},edit_key==field.key?buffer+"|":field.boolean?(field.get()?"true":"false"):number(field.get(),10),[this,&h,field]{if(field.boolean)h.edit([&]{field.set(field.get()?0:1);});else{edit_key=field.key;buffer=number(field.get(),12);replace_text=true;}},enabled);
|
||||
if(!field.boolean){for(const int direction:{-1,1})button({{x+width+(direction<0?-62.f:-30.f),y+20},{26,26}},direction<0?"-":"+",[&h,field,direction]{h.edit([&]{field.set(std::clamp(field.get()+field.step*direction,field.minimum,field.maximum));});},enabled);}
|
||||
}
|
||||
button({{x,ui.height-142},{width,28}},"Disconnect node wires",[this,&h]{const auto id=selected;h.edit([&]{std::erase_if(h.document.logic.links,[&](const auto& l){return l.from.node==id||l.to.node==id;});});},!h.live);
|
||||
const auto host=physical_host(h.document,*n);const float half=(width-8)/2;button({{x,ui.height-106},{half,28}},"Focus in 3D",[&h,host]{if(h.focus_physical)h.focus_physical(host);},host&&h.document.find(host));button({{x+half+8,ui.height-106},{half,28}},"Delete node [Del]",[this,&h]{remove(h);},!h.live);
|
||||
return result;
|
||||
}
|
||||
bool LogicEditor::Impl::commit(const LogicEditorHooks& h){
|
||||
if(edit_key.empty())return true;
|
||||
const auto key=edit_key,text=buffer;const auto id=selected;
|
||||
if(!h.document.logic.find(id)){edit_key.clear();return false;}
|
||||
try{double value=0;if(key!="name"){std::size_t used=0;value=std::stod(text,&used);if(used!=text.size()||!std::isfinite(value))throw std::invalid_argument("Enter a finite number");}
|
||||
if(key=="manual"&&h.live){if(!control(h,id,value))return false;edit_key.clear();return true;}
|
||||
const bool applied=h.edit([&]{auto& n=*h.document.logic.find(id);if(key=="name")n.name=text;else{auto f=fields(n);const auto at=std::find_if(f.begin(),f.end(),[&](const auto& f){return f.key==key;});if(at==f.end())throw std::invalid_argument("Property no longer exists");if(h.live&&at->restart)throw std::invalid_argument("This initial/schema property requires Build");if(value<at->minimum||value>at->maximum)throw std::invalid_argument("Value is outside the property range");at->set(value);}});
|
||||
if(applied)edit_key.clear();
|
||||
return applied;
|
||||
}catch(const std::exception& e){h.status(e.what());return false;}
|
||||
}
|
||||
bool LogicEditor::Impl::event(const sf::Event& event,const LogicEditorHooks& h,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){
|
||||
auto& graph=h.document.logic;std::erase_if(selection,[&](auto id){return !graph.find(id);});if(!selection.contains(selected))selected=selection.empty()?0:*selection.rbegin();
|
||||
if(pending)try{logic_port(graph,*pending);}catch(...){pending.reset();port_drag=false;}
|
||||
if(event.is<sf::Event::FocusLost>()){deactivate(h);return false;}
|
||||
if(!edit_key.empty()){
|
||||
if(const auto key=event.getIf<sf::Event::KeyPressed>()){if(key->code==sf::Keyboard::Key::Escape){edit_key.clear();return true;}if(key->code==sf::Keyboard::Key::Enter||key->code==sf::Keyboard::Key::Tab){commit(h);return true;}if(key->code==sf::Keyboard::Key::Backspace){if(replace_text)buffer.clear();else if(!buffer.empty())buffer.pop_back();replace_text=false;}return true;}
|
||||
if(const auto text=event.getIf<sf::Event::TextEntered>()){const auto c=text->unicode;const bool valid=edit_key=="name"?c>=32&&c<127:(c>='0'&&c<='9')||c=='-'||c=='+'||c=='.'||c=='e'||c=='E';if(valid){if(replace_text)buffer.clear();replace_text=false;if(buffer.size()<(edit_key=="name"?120:32))buffer+=char(c);}return true;}
|
||||
if(event.is<sf::Event::MouseButtonPressed>()&&!commit(h))return true;
|
||||
}
|
||||
if(search_focus){if(const auto key=event.getIf<sf::Event::KeyPressed>()){if(key->code==sf::Keyboard::Key::Escape||key->code==sf::Keyboard::Key::Enter)search_focus=false;else if(key->code==sf::Keyboard::Key::Backspace){if(search_replace)search.clear();else if(!search.empty())search.pop_back();search_replace=false;library_scroll=0;}return true;}if(const auto text=event.getIf<sf::Event::TextEntered>()){if(text->unicode>=32&&text->unicode<127){if(search_replace)search.clear();search_replace=false;if(search.size()<80)search+=char(text->unicode);library_scroll=0;}return true;}}
|
||||
if(const auto key=event.getIf<sf::Event::KeyPressed>()){
|
||||
using K=sf::Keyboard::Key;
|
||||
if(key->control){if(key->code==K::F&&panels){search_focus=true;search_replace=true;return true;}if(key->code==K::A){selection.clear();for(const auto& node:graph.nodes)selection.insert(node.id);selected=selection.empty()?0:*selection.rbegin();return true;}if(key->code==K::C){copy(h);return true;}if(key->code==K::V){paste(h);return true;}if(key->code==K::D){copy(h);paste(h);return true;}return false;}
|
||||
if(key->code==K::Home){fit(graph,canvas,false);return true;}if(key->code==K::F){fit(graph,canvas,true);return true;}
|
||||
if(key->code==K::Escape){if(held_button){if(h.release_buttons)h.release_buttons();held_button=0;}if(pending){pending.reset();port_drag=false;}else if(placing)placing.reset();else if(dragging||boxing||slider_drag){dragging=boxing=slider_drag=false;drag_offset={};}else select(0,h);return true;}
|
||||
if(key->code==K::Delete||key->code==K::Backspace){if(pending&&!h.live){const auto pin=*pending;h.edit([&]{graph.disconnect(pin);});pending.reset();}else remove(h);return true;}
|
||||
if(key->code==K::Q||key->code==K::G||key->code==K::R||key->code==K::C||key->code==K::P||key->code==K::X)return true;
|
||||
return false;
|
||||
}
|
||||
if(const auto moved=event.getIf<sf::Event::MouseMoved>()){
|
||||
mouse=sf::Vector2f(moved->position);const auto delta=mouse-previous;previous=mouse;
|
||||
if(panning){center.x-=delta.x/zoom;center.y-=delta.y/zoom;return true;}
|
||||
if(dragging){drag_offset=mouse-press;return true;}
|
||||
if(slider_drag){if(const auto n=graph.find(slider_node)){const auto r=rect(*n,canvas);const auto ratio=std::clamp((mouse.x-r.position.x-12*zoom)/((node_width-24)*zoom),0.f,1.f);slider_preview=std::lerp(n->minimum,n->maximum,double(ratio));if(h.live)control(h,n->id,slider_preview);}return true;}
|
||||
return boxing||port_drag||canvas.contains(mouse)||(panels&&(ui.left.contains(mouse)||ui.right.contains(mouse)));
|
||||
}
|
||||
if(const auto released=event.getIf<sf::Event::MouseButtonReleased>()){
|
||||
mouse=sf::Vector2f(released->position);if(released->button==sf::Mouse::Button::Middle&&panning){panning=false;return true;}
|
||||
if(released->button!=sf::Mouse::Button::Left)return false;
|
||||
if(held_button){const auto id=held_button;held_button=0;if(h.live)control(h,id,false);return true;}
|
||||
if(slider_drag){slider_drag=false;if(!h.live){const auto id=slider_node;const auto v=slider_preview;h.edit([&]{if(auto n=graph.find(id))n->value=v;});}return true;}
|
||||
if(dragging){dragging=false;const auto delta=drag_offset/zoom;drag_offset={};if(delta.length()>1){const auto ids=selection;h.edit([&]{for(const auto id:ids)if(auto n=graph.find(id)){n->position.x+=delta.x;n->position.y+=delta.y;}});}return true;}
|
||||
if(boxing){boxing=false;const sf::FloatRect region{{std::min(press.x,mouse.x),std::min(press.y,mouse.y)},{std::abs(mouse.x-press.x),std::abs(mouse.y-press.y)}};for(const auto& n:graph.nodes)if(region.findIntersection(rect(n,canvas)))selection.insert(n.id);selected=selection.empty()?0:*selection.rbegin();return true;}
|
||||
if(port_drag){port_drag=false;if((mouse-press).length()>5&&canvas.contains(mouse))if(const auto target=pick_pin(graph,mouse,canvas);target&&pending&&*target!=*pending)connect(h,*target);return true;}
|
||||
return canvas.contains(mouse);
|
||||
}
|
||||
if(const auto wheel=event.getIf<sf::Event::MouseWheelScrolled>()){
|
||||
const auto point=sf::Vector2f(wheel->position);
|
||||
if(canvas.contains(point)){const auto before=unproject(point,canvas);zoom=std::clamp(zoom*std::exp(wheel->delta*.12f),.25f,2.f);const auto after=unproject(point,canvas);center.x+=before.x-after.x;center.y+=before.y-after.y;return true;}
|
||||
if(panels&&ui.left.contains(point)){library_scroll=std::max(0,library_scroll+(wheel->delta<0?1:-1));return true;}
|
||||
if(panels&&ui.right.contains(point)){field_scroll=std::max(0,field_scroll+(wheel->delta<0?1:-1));return true;}return false;
|
||||
}
|
||||
if(const auto pressed=event.getIf<sf::Event::MouseButtonPressed>()){
|
||||
mouse=previous=sf::Vector2f(pressed->position);
|
||||
if(panels&&ui.left.size.x>40&&ui.search.contains(mouse)&&pressed->button==sf::Mouse::Button::Left){search_focus=true;search_replace=false;return true;}search_focus=false;
|
||||
if(panels&&pressed->button==sf::Mouse::Button::Left){auto list=actions(h,ui,canvas,panels);for(auto i=list.rbegin();i!=list.rend();++i)if(i->rect.contains(mouse)){if(i->enabled)i->run();else h.status(h.live?"This schema or initial-state property requires Build.":"Choose a compatible physical binding first.");return true;}}
|
||||
if(!canvas.contains(mouse))return panels&&(ui.left.contains(mouse)||ui.right.contains(mouse));
|
||||
if(pressed->button==sf::Mouse::Button::Middle){panning=true;return true;}
|
||||
if(pressed->button==sf::Mouse::Button::Right){if(const auto pin=pick_pin(graph,mouse,canvas);pin&&!h.live){const auto endpoint=*pin;h.edit([&]{graph.disconnect(endpoint);});pending.reset();}else if(const auto n=pick(graph,mouse,canvas))select(n->id,h);return true;}
|
||||
if(pressed->button!=sf::Mouse::Button::Left)return true;
|
||||
if(placing){create(h,canvas);return true;}
|
||||
if(const auto hit=pick_pin(graph,mouse,canvas);hit){if(h.live){select(hit->node,h);return true;}if(pending&&*pending!=*hit){connect(h,*hit);return true;}pending=*hit;rewiring=logic_port(graph,*hit).direction==PortDirection::Input;port_drag=true;press=mouse;return true;}
|
||||
if(const auto n=pick(graph,mouse,canvas)){
|
||||
const auto id=n->id;const auto r=rect(*n,canvas);const float control_y=r.position.y+r.size.y-42*zoom;
|
||||
if(has_control(n->kind)&&mouse.y>=control_y){select(id,h);if(manual_boolean(n->kind)){if(n->kind==LogicNodeKind::Button){if(h.live){if(control(h,id,true))held_button=id;}else h.status("Momentary buttons are active during Test; their initial state is released.");}else{bool current=n->boolean;if(const auto live=live_value(h,*n,"out");live&&live->validity==SignalValidity::Valid)current=std::get<bool>(live->value);if(h.live&&manual_values.contains(id))current=std::get<bool>(manual_values.at(id));if(h.live)control(h,id,!current);else h.edit([&]{graph.find(id)->boolean=!current;});}}
|
||||
else if(n->kind==LogicNodeKind::Slider){slider_drag=true;slider_node=id;slider_preview=n->value;sf::Event moved=sf::Event::MouseMoved{pressed->position};this->event(moved,h,ui,canvas,panels);}
|
||||
else {edit_key=h.live?"manual":"value";if(const auto live=live_value(h,*n,"out");h.live&&live&&live->validity==SignalValidity::Valid)buffer=number(std::get<double>(live->value),12);else buffer=number(n->value,12);replace_text=true;}
|
||||
return true;
|
||||
}
|
||||
if(!selection.contains(id)||h.shift)select(id,h,h.shift);
|
||||
else {selected=id;if(h.select_physical)h.select_physical(physical_host(h.document,*n));}
|
||||
if(mouse.y<r.position.y+header_height*zoom){dragging=true;press=mouse;drag_offset={};}
|
||||
return true;
|
||||
}
|
||||
if(pending){h.status("Choose a compatible pin, or place a compatible node from the filtered library. Escape cancels.");return true;}
|
||||
if(!h.shift)select(0,h);
|
||||
boxing=true;press=mouse;return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
namespace {
|
||||
sf::Vector2f curve(sf::Vector2f a,sf::Vector2f b,float t){const auto span=std::max(60.f,std::abs(b.x-a.x)*.45f);const auto c=a+sf::Vector2f{span,0},d=b-sf::Vector2f{span,0};const float u=1-t;return a*(u*u*u)+c*(3*u*u*t)+d*(3*u*t*t)+b*(t*t*t);}
|
||||
void wire(sf::RenderTarget& target,sf::Vector2f a,sf::Vector2f b,sf::Color color,bool dashed=false){auto previous=a;for(int i=1;i<=40;++i){const auto next=curve(a,b,float(i)/40);if(!dashed||i%4<2)line(target,previous,next,color);previous=next;}}
|
||||
}
|
||||
void LogicEditor::Impl::draw(sf::RenderWindow& window,const sf::Font* font,const LogicEditorHooks& h,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){
|
||||
if(needs_fit)fit(h.document.logic,canvas,false);
|
||||
const auto original=window.getView();const auto size=window.getSize();
|
||||
sf::View clipped(canvas);clipped.setViewport({{canvas.position.x/size.x,canvas.position.y/size.y},{canvas.size.x/size.x,canvas.size.y/size.y}});window.setView(clipped);box(window,canvas,background,border);
|
||||
const auto zero=project({},canvas);const float grid=40*zoom;for(float x=canvas.position.x+std::fmod(zero.x-canvas.position.x,grid);x<canvas.position.x+canvas.size.x;x+=grid)line(window,{x,canvas.position.y},{x,canvas.position.y+canvas.size.y},sf::Color{29,35,42});for(float y=canvas.position.y+std::fmod(zero.y-canvas.position.y,grid);y<canvas.position.y+canvas.size.y;y+=grid)line(window,{canvas.position.x,y},{canvas.position.x+canvas.size.x,y},sf::Color{29,35,42});
|
||||
std::string hover,hover_detail;std::set<std::uint32_t> cycle;
|
||||
if(pending)if(const auto target=pick_pin(h.document.logic,mouse,canvas);target&&*target!=*pending){const auto result=preview(h.document.logic,*target);if(!result.allowed&&result.message.starts_with("Combinational cycle:")){auto graph=h.document.logic;if(rewiring)graph.disconnect(*pending);auto from=*pending,to=*target;if(logic_port(graph,from).direction==PortDirection::Input)std::swap(from,to);graph.links.push_back({from,to});std::vector<std::uint32_t> path;try{logic_evaluation_order(graph,&path);}catch(const std::exception&){}cycle.insert(path.begin(),path.end());}}
|
||||
for(const auto& link:h.document.logic.links){const auto from=h.document.logic.find(link.from.node),to=h.document.logic.find(link.to.node);if(!from||!to)continue;const auto descriptor=logic_port(h.document.logic,link.from);auto color=port_color(descriptor.domain);const auto value=live_value(h,*from,link.from.port);const bool invalid=value&&value->validity!=SignalValidity::Valid;if(value&&value->validity==SignalValidity::Valid&&descriptor.domain==PortDomain::Boolean&&!std::get<bool>(value->value))color=sf::Color(color.r/2,color.g/2,color.b/2);wire(window,pin(*from,link.from.port,canvas),pin(*to,link.to.port,canvas),color,invalid);}
|
||||
for(const auto& node:h.document.logic.nodes){const auto r=rect(node,canvas);if(!r.findIntersection(canvas))continue;const bool active=selection.contains(node.id);const auto state=live_node(h,node.id);const bool fault=state&&!state->problem.empty();
|
||||
box(window,r,sf::Color{29,34,42},cycle.contains(node.id)?amber:active?accent:fault?amber:border);box(window,{r.position,{r.size.x,header_height*zoom}},cycle.contains(node.id)?sf::Color{75,56,35}:active?sf::Color{39,57,78}:sf::Color{37,43,52});
|
||||
const auto title_size=unsigned(std::clamp(12*zoom,8.f,14.f));label(window,font,node.name,r.position.x+10*zoom,r.position.y+8*zoom,r.size.x-20*zoom,title_size,active?accent:text_color);
|
||||
if(zoom>=.65f){std::string description=logic_node_name(node.kind);if(node.kind==LogicNodeKind::Sensor){const auto sensor=h.document.find_sensor(node.sensor);description=sensor?"S"+std::to_string(sensor->id)+" / "+sensor->name:"Unresolved sensor / "+node.binding_label;}else if(node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu){const auto part=h.document.find(node.part);description=part?std::string(node.kind==LogicNodeKind::Ecu?"ECU inputs":logic_command_name(node.command))+" / "+part->name:"Unresolved target / "+node.binding_label;}
|
||||
label(window,font,description,r.position.x+10*zoom,r.position.y+40*zoom,r.size.x-20*zoom,unsigned(std::max(9.f,10*zoom)),muted);
|
||||
}
|
||||
const auto ports=logic_node_ports(node,&h.document);for(std::size_t index=0;index<ports.size();++index){const auto& port=ports[index];const auto at=pin(node,port.key,canvas);const bool input=port.direction==PortDirection::Input;auto color=port_color(port.domain);const auto value=state&&index<state->values.size()?&state->values[index]:nullptr;
|
||||
if(value&&value->validity==SignalValidity::Valid&&port.domain==PortDomain::Boolean&&!std::get<bool>(value->value))color=sf::Color(color.r/2,color.g/2,color.b/2);
|
||||
const auto type_color=port_color(port.domain);circle(window,at,std::max(3.f,5.f*zoom),filled_port(port)?color:background,type_color);
|
||||
if(pending&&pending->node==node.id&&pending->port==port.key)circle(window,at,std::max(6.f,8*zoom),sf::Color::Transparent,accent);
|
||||
if(zoom>=.65f){const float x=input?r.position.x+12*zoom:r.position.x+126*zoom;const float width=102*zoom;label(window,font,port.name,x,at.y-6*zoom,width,unsigned(std::max(9.f,11*zoom)),type_color);if(h.live)label(window,font,reading(value,port),x,at.y+9*zoom,width,unsigned(std::max(8.f,10*zoom)),value&&value->validity!=SignalValidity::Valid?amber:muted);}
|
||||
if(canvas.contains(mouse)&&(mouse-at).length()<10){hover=port.name+" / "+port_domain_key(port.domain)+(input?(port.required?" required input":" optional input"):" output");hover_detail=port.domain==PortDomain::Number?std::string(signal_quantity_key(port.quantity))+" ["+signal_quantity_unit(port.quantity)+"]":"Boolean";if(port.quantity==SignalQuantity::Pressure){const char* reference[]{"none","absolute","gauge","differential"};hover_detail+=" / "+std::string(reference[int(port.pressure_reference)]);}if(port.minimum||port.maximum)hover_detail+=" / "+(port.minimum?number(*port.minimum):"open")+".."+(port.maximum?number(*port.maximum):"open");if(std::holds_alternative<bool>(port.default_value))hover_detail+=std::get<bool>(port.default_value)?" / default true":" / default false";else if(std::holds_alternative<double>(port.default_value))hover_detail+=" / default "+number(std::get<double>(port.default_value));if(h.live)hover_detail+=" / "+reading(value,port);if(pending&&*pending!=LogicEndpoint{node.id,port.key})hover_detail=preview(h.document.logic,{node.id,port.key}).message;}
|
||||
}
|
||||
if(has_control(node.kind)){
|
||||
const sf::FloatRect control{{r.position.x+12*zoom,r.position.y+r.size.y-40*zoom},{r.size.x-24*zoom,28*zoom}};box(window,control,background,border);std::string title;
|
||||
const auto value=live_value(h,node,"out");
|
||||
if(h.live&&manual_values.contains(node.id)&&value&&value->validity==SignalValidity::Valid&&manual_values.at(node.id)==value->value)manual_values.erase(node.id);
|
||||
const bool queued=h.live&&manual_values.contains(node.id);
|
||||
if(manual_boolean(node.kind)){bool active=node.kind==LogicNodeKind::Switch?node.boolean:false;if(value&&value->validity==SignalValidity::Valid)active=std::get<bool>(value->value);if(queued)active=std::get<bool>(manual_values.at(node.id));title=node.kind==LogicNodeKind::Button?(held_button==node.id?"HELD":"Hold button"):std::string(queued?"Queued ":"")+(active?"ON / click to toggle":"OFF / click to toggle");label(window,font,title,control.position.x+6*zoom,control.position.y+6*zoom,control.size.x-12*zoom,unsigned(std::max(8.f,11*zoom)),port_color(PortDomain::Boolean));}
|
||||
else {double current=node.value;if(value&&value->validity==SignalValidity::Valid)current=std::get<double>(value->value);if(queued)current=std::get<double>(manual_values.at(node.id));if(slider_drag&&slider_node==node.id)current=slider_preview;
|
||||
if(node.kind==LogicNodeKind::Slider&&node.maximum>node.minimum){const float fraction=float(std::clamp((current-node.minimum)/(node.maximum-node.minimum),0.,1.));box(window,{control.position,{control.size.x*fraction,control.size.y}},sf::Color{32,72,53});}
|
||||
title=(edit_key=="manual"||(edit_key=="value"&&node.kind==LogicNodeKind::NumberInput))&&selected==node.id?buffer+"|":number(current,6)+" "+signal_quantity_unit(node.quantity)+(queued?" (queued)":"");label(window,font,title,control.position.x+6*zoom,control.position.y+6*zoom,control.size.x-12*zoom,unsigned(std::max(8.f,11*zoom)),port_color(PortDomain::Number));
|
||||
}
|
||||
}
|
||||
if(node.kind==LogicNodeKind::Readout&&h.live){const auto value=live_value(h,node,"in");auto& trace=traces[node.id];if(value&&(trace.empty()||trace.back().first!=value->timestamp)){trace.push_back({value->timestamp,value->validity==SignalValidity::Valid?std::get<double>(value->value):std::numeric_limits<double>::quiet_NaN()});if(trace.size()>128)trace.pop_front();}
|
||||
double lo=std::numeric_limits<double>::infinity(),hi=-lo;for(const auto& point:trace)if(std::isfinite(point.second)){lo=std::min(lo,point.second);hi=std::max(hi,point.second);}const auto area=sf::FloatRect({r.position.x+12*zoom,r.position.y+r.size.y-36*zoom},{r.size.x-24*zoom,24*zoom});box(window,area,background);
|
||||
if(trace.size()>1&&std::isfinite(lo)){const double range=std::max(1e-12,hi-lo);for(std::size_t i=1;i<trace.size();++i)if(std::isfinite(trace[i-1].second)&&std::isfinite(trace[i].second)){const auto p=[&](std::size_t at){return area.position+sf::Vector2f{area.size.x*float(at)/float(trace.size()-1),area.size.y*(1-float((trace[at].second-lo)/range))};};line(window,p(i-1),p(i),port_color(PortDomain::Number));}}
|
||||
}
|
||||
}
|
||||
if(pending){const auto n=h.document.logic.find(pending->node);if(n){const auto port=logic_port(h.document.logic,*pending);const auto origin=pin(*n,pending->port,canvas);wire(window,port.direction==PortDirection::Output?origin:mouse,port.direction==PortDirection::Output?mouse:origin,port_color(port.domain));}}
|
||||
if(placing){LogicNode ghost;ghost.kind=*placing;const auto at=unproject(mouse,canvas);ghost.position=at;if(pending)configure(ghost,logic_port(h.document.logic,*pending));const auto r=rect(ghost,canvas);box(window,r,sf::Color{27,31,38,190},accent);label(window,font,logic_node_name(ghost.kind),r.position.x+8,r.position.y+8,r.size.x-16,11,accent);}
|
||||
if(boxing)box(window,{{std::min(press.x,mouse.x),std::min(press.y,mouse.y)},{std::abs(press.x-mouse.x),std::abs(press.y-mouse.y)}},sf::Color{116,170,235,28},accent);
|
||||
label(window,font,std::string("LOGIC / ")+(h.live?number(h.live->time,4)+" s / ":"")+std::to_string(h.document.logic.nodes.size())+" nodes / "+number(zoom*100,3)+"%",canvas.position.x+12,canvas.position.y+10,canvas.size.x-24,11,accent);
|
||||
if(h.document.logic.nodes.empty()){label(window,font,"Build a signal graph",canvas.position.x+26,canvas.position.y+64,canvas.size.x-52,18);label(window,font,"Pick a node on the left. Drag pins or click a pin then its target.",canvas.position.x+26,canvas.position.y+100,canvas.size.x-52,11,muted);}
|
||||
if(canvas.contains(mouse)&&!hover.empty()){const auto width=std::min(460.f,canvas.size.x-24),x=std::clamp(mouse.x+12,canvas.position.x+8,canvas.position.x+canvas.size.x-width-8),y=std::clamp(mouse.y+24,canvas.position.y+36,canvas.position.y+canvas.size.y-82);box(window,{{x,y},{width,68}},background,accent);label(window,font,hover,x+8,y+8,width-16,10,text_color);label(window,font,hover_detail,x+8,y+26,width-16,10,muted);label(window,font,pending?"Escape cancels / connected inputs can be rewired":"Input = ring / output = filled circle",x+8,y+44,width-16,10,muted);}
|
||||
window.setView(original);
|
||||
if(!panels)return;
|
||||
window.setView(sf::View(sf::FloatRect({0,0},{float(size.x),float(size.y)})));
|
||||
if(ui.left.size.x>40){box(window,ui.left,panel);box(window,ui.search,background,search_focus?accent:border);label(window,font,search.empty()&&!search_focus?"Search logic nodes...":search+(search_focus?"|":""),ui.search.position.x+8,ui.search.position.y+7,ui.search.size.x-16,12,search_focus?text_color:muted);const auto entries=items(h);if(library_tab)label(window,font,std::to_string(entries.size())+" nodes / F focuses selection",20,198,ui.left.size.x-24,10,muted);if(entries.empty())label(window,font,"No compatible matching nodes",20,236,ui.left.size.x-24,11,muted);
|
||||
label(window,font,pending?"Library filtered by pending pin":"RED Boolean / GREEN Number",20,ui.height-220,ui.left.size.x-24,11,muted);label(window,font,"Inputs: rings / outputs: filled",20,ui.height-196,ui.left.size.x-24,10,muted);label(window,font,"Wheel: zoom / middle drag: pan",20,ui.height-172,ui.left.size.x-24,10,muted);
|
||||
}
|
||||
if(ui.right.size.x>40){box(window,ui.right,panel);const float x=ui.inspector_x+12,width=ui.inspector_width-24;label(window,font,"LOGIC INSPECTOR",x,109,width,13,muted);const auto n=h.document.logic.find(selected);
|
||||
if(n){label(window,font,logic_node_name(n->kind),x,175,width,11,muted);const auto f=fields(*h.document.logic.find(selected));const int rows=std::max(1,int((ui.height-190-310)/52));for(int row=0;row<rows&&row+field_scroll<int(f.size());++row){const auto& field=f[row+field_scroll];label(window,font,field.label+(h.live&&field.restart?" / BUILD":""),x,310+row*52.f,width,11,h.live&&field.restart?muted:text_color);}
|
||||
std::string problem=logic_binding_problem(h.document,*n);if(const auto state=live_node(h,n->id);state){if(!state->problem.empty())problem=state->problem;if(!state->values.empty())label(window,font,"Sample "+number(state->values[0].timestamp,8)+" s",x,ui.height-196,width,10,muted);}label(window,font,problem.empty()?(h.live?"Live tuning / initial fields require Build":"Click values to edit. Enter applies."):problem,x,ui.height-178,width,10,problem.empty()?muted:amber);
|
||||
if(int(f.size())>rows)label(window,font,"Scroll properties: "+std::to_string(field_scroll+1)+" / "+std::to_string(f.size()),x,ui.height-158,width,10,accent);
|
||||
}else{label(window,font,"Select a node to inspect",x,148,width,13);label(window,font,"Shift selects several / drag headers",x,184,width,11,muted);label(window,font,"Delete removes / Ctrl+Z undoes",x,210,width,11,muted);label(window,font,"Right-click a pin to disconnect it",x,236,width,11,muted);}
|
||||
}
|
||||
std::string action_help;for(const auto& action:actions(h,ui,canvas,panels)){const bool over=action.rect.contains(mouse),small=action.rect.size.x<44;const float inset=small?3.f:8.f;const unsigned text_size=small?9:11;box(window,action.rect,action.active?sf::Color{38,57,79}:over&&action.enabled?sf::Color{40,46,57}:panel,action.active?accent:border);label(window,font,action.title,action.rect.position.x+inset,action.rect.position.y+6,action.rect.size.x-inset*2,text_size,action.enabled?text_color:sf::Color{100,110,125});if(over&&(fitted(font,action.title,action.rect.size.x-inset*2,text_size)!=action.title||!action.enabled))action_help=action.title+(action.enabled?"":action.title=="Focus in 3D"?" / This node has no physical host.":" / Return to Build for this edit.");}
|
||||
if(!action_help.empty()){const auto area=ui.right.contains(mouse)?ui.right:ui.left;const float x=area.position.x+12,width=area.size.x-24,y=std::clamp(mouse.y+32,area.position.y+10,area.position.y+area.size.y-80);box(window,{{x,y},{width,70}},background,accent);wrapped(window,font,action_help,x+8,y+8,width-16,10,3,text_color);}
|
||||
window.setView(original);
|
||||
}
|
||||
LogicEditor::LogicEditor():impl_(std::make_unique<Impl>()){}
|
||||
LogicEditor::~LogicEditor()=default;
|
||||
void LogicEditor::reset_document(){auto next=std::make_unique<Impl>();next->epoch=impl_->epoch+1;next->clipboard_epoch=impl_->clipboard_epoch;next->clipboard=std::move(impl_->clipboard);impl_=std::move(next);}
|
||||
void LogicEditor::deactivate(const LogicEditorHooks& hooks){impl_->deactivate(hooks);}
|
||||
bool LogicEditor::editing_text()const{return !impl_->edit_key.empty()||impl_->search_focus;}
|
||||
bool LogicEditor::active_gesture()const{return impl_->dragging||impl_->boxing||impl_->panning||impl_->slider_drag||impl_->port_drag||impl_->held_button;}
|
||||
std::uint32_t LogicEditor::selected_node()const{return impl_->selected;}
|
||||
void LogicEditor::select_node(std::uint32_t id,const MachineDocument& doc){impl_->selection.clear();if(doc.logic.find(id))impl_->selection.insert(id);impl_->selected=impl_->selection.empty()?0:id;impl_->field_scroll=0;impl_->edit_key.clear();}
|
||||
void LogicEditor::select_part_reference(std::uint32_t part,const MachineDocument& doc){if(!part)return;for(const auto& n:doc.logic.nodes)if(physical_host(doc,n)==part){select_node(n.id,doc);return;}}
|
||||
void LogicEditor::fit(const LogicGraph& graph,sf::FloatRect canvas,bool only){impl_->fit(graph,canvas,only);}
|
||||
void LogicEditor::reveal(std::uint32_t id,const MachineDocument& doc,sf::FloatRect canvas){select_node(id,doc);impl_->fit(doc.logic,canvas,true);}
|
||||
sf::Vector2f LogicEditor::project(LogicPosition point,sf::FloatRect canvas)const{return impl_->project(point,canvas);}
|
||||
LogicPosition LogicEditor::unproject(sf::Vector2f point,sf::FloatRect canvas)const{return impl_->unproject(point,canvas);}
|
||||
sf::FloatRect LogicEditor::node_rect(const LogicNode& node,sf::FloatRect canvas)const{return impl_->rect(node,canvas);}
|
||||
sf::Vector2f LogicEditor::pin_position(const LogicNode& node,const std::string& port,sf::FloatRect canvas)const{return impl_->pin(node,port,canvas);}
|
||||
bool LogicEditor::event(const sf::Event& event,const LogicEditorHooks& hooks,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){if(impl_->needs_fit)impl_->fit(hooks.document.logic,canvas,false);return impl_->event(event,hooks,ui,canvas,panels);}
|
||||
void LogicEditor::draw(sf::RenderWindow& window,const sf::Font* font,const LogicEditorHooks& hooks,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){impl_->draw(window,font,hooks,ui,canvas,panels);}
|
||||
}
|
||||
52
src/logic_example.cpp
Normal file
52
src/logic_example.cpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
|
||||
namespace fluidsim::port {
|
||||
MachineDocument machine_logic_example(){
|
||||
auto doc=machine_pump_example();doc.name="Automatic air supply / logic bench";
|
||||
const auto tank=doc.parts[0].id,pump=doc.parts[1].id,air=doc.parts[2].id;
|
||||
doc.find(tank)->position={.4f,.28f,0};doc.find(tank)->size={.12f,.12f,.12f};doc.find(tank)->name="Compressed air tank";
|
||||
doc.find(air)->position={-.4f,.32f,0};doc.find(air)->name="Intake atmosphere";
|
||||
for(auto& link:doc.links)for(auto* endpoint:{&link.a,&link.b}){if(endpoint->part==tank){endpoint->part=air;endpoint->port=0;}else if(endpoint->part==air){endpoint->part=tank;endpoint->port=0;}}
|
||||
doc.sync_connections();const auto shaft=machine_shaft_for(doc,pump);doc.find(shaft)->shaft.inertia=.01f;doc.find(shaft)->shaft.initial_speed=0;doc.find(shaft)->shaft.starter_torque=0;
|
||||
std::uint32_t motor=0;for(const auto& part:doc.parts)if(part.kind==MachinePartKind::DriveMotor)motor=part.id;
|
||||
const auto pressure=doc.add_sensor(MachineSensorKind::Pressure,tank).id;doc.find_sensor(pressure)->name="Tank absolute pressure";
|
||||
const auto crank=doc.add_sensor(MachineSensorKind::Crank,shaft).id;
|
||||
auto& graph=doc.logic;
|
||||
const auto tap=graph.add(LogicNodeKind::Sensor,{0,0}).id;graph.find(tap)->sensor=pressure;graph.find(tap)->name="Tank pressure";
|
||||
const auto threshold=graph.add(LogicNodeKind::Hysteresis,{300,0}).id;auto& h=*graph.find(threshold);h.name="Compressor demand";h.quantity=SignalQuantity::Pressure;h.pressure_reference=PressureReference::Absolute;h.low=120000;h.high=150000;h.below=true;h.initial=true;
|
||||
const auto enable=graph.add(LogicNodeKind::Switch,{300,240}).id;graph.find(enable)->name="Master enable";graph.find(enable)->boolean=true;
|
||||
const auto gate=graph.add(LogicNodeKind::And,{600,0}).id;graph.find(gate)->name="Enabled demand";
|
||||
const auto command=graph.add(LogicNodeKind::Command,{900,0}).id;graph.find(command)->name="Compressor motor enable";graph.find(command)->part=motor;graph.find(command)->binding_label=doc.find(motor)->name;
|
||||
const auto speed=graph.add(LogicNodeKind::Slider,{600,260}).id;auto& slider=*graph.find(speed);slider.name="Compressor speed (rad/s)";slider.quantity=SignalQuantity::AngularSpeed;slider.minimum=0;slider.maximum=300;slider.value=900*2*double(pi)/60;
|
||||
const auto target=graph.add(LogicNodeKind::Command,{900,260}).id;graph.find(target)->name="Motor speed target";graph.find(target)->command=LogicCommand::MotorSpeed;graph.find(target)->part=motor;
|
||||
const auto display=graph.add(LogicNodeKind::Readout,{300,-180}).id;graph.find(display)->name="Tank pressure readout";graph.find(display)->quantity=SignalQuantity::Pressure;graph.find(display)->pressure_reference=PressureReference::Absolute;
|
||||
const auto lamp=graph.add(LogicNodeKind::Lamp,{900,-180}).id;graph.find(lamp)->name="Compressor running demand";
|
||||
const auto pickup=graph.add(LogicNodeKind::Sensor,{0,380}).id;graph.find(pickup)->sensor=crank;graph.find(pickup)->sensor_kind=MachineSensorKind::Crank;graph.find(pickup)->name="Compressor crank pickup";
|
||||
const auto rpm=graph.add(LogicNodeKind::Readout,{300,420}).id;graph.find(rpm)->quantity=SignalQuantity::AngularSpeed;graph.find(rpm)->name="Measured angular speed";
|
||||
graph.connect({tap,"value"},{threshold,"in"});graph.connect({threshold,"out"},{gate,"in1"});graph.connect({enable,"out"},{gate,"in2"});graph.connect({gate,"out"},{command,"in"});graph.connect({speed,"out"},{target,"in"});graph.connect({tap,"value"},{display,"in"});graph.connect({gate,"out"},{lamp,"in"});graph.connect({pickup,"speed"},{rpm,"in"});
|
||||
doc.probes={{1,tank,"Tank absolute pressure",MachineQuantity::Pressure},{2,shaft,"Compressor shaft RPM",MachineQuantity::Rpm},{3,motor,"Drive power",MachineQuantity::Power}};
|
||||
validate_machine(doc);return doc;
|
||||
}
|
||||
MachineDocument machine_sensor_engine_example(bool injection){
|
||||
auto doc=machine_engine_example();doc.name=injection?"Sensor Alpha-N EFI bench":"Sensor crank ignition / carburetor bench";
|
||||
std::uint32_t ecu=0,cylinder=0,throttle=0,intake=0;
|
||||
for(auto& part:doc.parts){if(part.kind==MachinePartKind::Ecu){ecu=part.id;part.sensor_ecu.enabled=true;part.sensor_ecu.strategy=injection?SensorEcuConfig::Strategy::AlphaN:SensorEcuConfig::Strategy::Ignition;part.injection_enabled=false;part.ecu.rev_limit_enabled=false;part.name=injection?"Alpha-N ECU":"Crank ignition ECU";}
|
||||
if(part.kind==MachinePartKind::Cylinder)cylinder=part.id;
|
||||
if(part.kind==MachinePartKind::Throttle){throttle=part.id;if(!injection){part.kind=MachinePartKind::Carburetor;part.name="Physical carburetor";}part.pedal_control=false;part.opening=.5f;}
|
||||
if(part.kind==MachinePartKind::Chamber&&part.position.x<0)intake=part.id;
|
||||
if(part.kind==MachinePartKind::DriveMotor)part.drive.target_speed=3000*2*pi/60;
|
||||
}
|
||||
if(injection)doc.find(ecu)->sensor_ecu.fuel_per_cycle={.000002f,.00004f,.000002f,.00004f};
|
||||
const auto shaft=machine_primary_shaft(doc);bool driven=false;for(const auto& part:doc.parts)if(part.kind==MachinePartKind::DriveMotor)driven=true;
|
||||
if(!driven){const auto motor=doc.add(MachinePartKind::DriveMotor,machine_math::add(doc.find(shaft)->position,{-.3f,0,0})).id;doc.find(motor)->name="3000 RPM test drive";doc.find(motor)->drive.target_speed=3000*2*pi/60;doc.connect({shaft,0},{motor,0});}
|
||||
std::erase_if(doc.links,[&](const auto& link){return link.kind==MachineConnectionKind::Control&&link.b.part!=cylinder;});
|
||||
const auto sensor=doc.add_sensor(MachineSensorKind::Crank,machine_primary_shaft(doc)).id;
|
||||
auto& graph=doc.logic;const auto pickup=graph.add(LogicNodeKind::Sensor,{0,0}).id;graph.find(pickup)->sensor=sensor;graph.find(pickup)->sensor_kind=MachineSensorKind::Crank;graph.find(pickup)->name="Crank pickup";
|
||||
const auto connector=graph.add(LogicNodeKind::Ecu,{340,0}).id;graph.find(connector)->part=ecu;graph.find(connector)->ecu_strategy=doc.find(ecu)->sensor_ecu.strategy;graph.find(connector)->name="Engine ECU inputs";graph.find(connector)->binding_label=doc.find(ecu)->name;
|
||||
graph.connect({pickup,"speed"},{connector,"speed"});graph.connect({pickup,"angle"},{connector,"angle"});
|
||||
if(injection){const auto tps=doc.add_sensor(MachineSensorKind::ThrottlePosition,throttle).id;const auto node=graph.add(LogicNodeKind::Sensor,{0,230}).id;graph.find(node)->sensor=tps;graph.find(node)->sensor_kind=MachineSensorKind::ThrottlePosition;graph.find(node)->name="Actual throttle position";graph.connect({node,"value"},{connector,"tps"});doc.find(intake)->injector=MachineInjectorConfig{};doc.find(intake)->injector->ecu=ecu;doc.find(intake)->injector->ecu_label=doc.find(ecu)->name;}
|
||||
doc.probes={{1,cylinder,"Cylinder pressure",MachineQuantity::Pressure},{2,machine_primary_shaft(doc),"Crank RPM",MachineQuantity::Rpm},{3,cylinder,"Charge AFR",MachineQuantity::ChargeAfr}};
|
||||
validate_machine(doc);return doc;
|
||||
}
|
||||
}
|
||||
143
src/logic_runtime.cpp
Normal file
143
src/logic_runtime.cpp
Normal file
@@ -0,0 +1,143 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <deque>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace fluidsim::port {
|
||||
struct MachineLogicRuntime::Impl {
|
||||
struct Source {int node{-1},port{-1};};
|
||||
struct Node {
|
||||
std::vector<MachinePortDescriptor> ports;
|
||||
std::vector<Source> sources;
|
||||
std::vector<SignalValue> values;
|
||||
std::vector<unsigned> inputs,outputs;
|
||||
SignalValue manual,stored,next;
|
||||
bool hysteresis{};
|
||||
bool sensor_bound{};
|
||||
std::uint32_t ecu_shaft{};
|
||||
const char* problem{};
|
||||
std::deque<SignalValue> events;
|
||||
};
|
||||
LogicGraph graph;
|
||||
SensorReader reader;
|
||||
std::vector<Node> nodes;
|
||||
std::unordered_map<std::uint32_t,std::size_t> by_id;
|
||||
std::vector<std::size_t> order;
|
||||
std::vector<LogicCommandState> commands;
|
||||
std::vector<LogicEcuInputs> ecu_inputs;
|
||||
explicit Impl(const MachineDocument& document,SensorReader source):graph(document.logic),reader(std::move(source)){
|
||||
validate_machine_logic(document);const auto missing=logic_required_inputs(graph);if(!missing.empty())throw std::invalid_argument(missing.front());
|
||||
nodes.reserve(graph.nodes.size());
|
||||
for(std::size_t i=0;i<graph.nodes.size();++i){const auto& definition=graph.nodes[i];if(definition.kind==LogicNodeKind::Command||definition.kind==LogicNodeKind::Ecu){const auto problem=logic_binding_problem(document,definition);if(!problem.empty())throw std::invalid_argument(definition.name+": "+problem);}
|
||||
Node node;node.ports=logic_node_ports(definition,&document);node.sources.resize(node.ports.size());node.values.resize(node.ports.size());node.hysteresis=definition.initial;
|
||||
node.sensor_bound=definition.kind==LogicNodeKind::Sensor&&logic_binding_problem(document,definition).empty();
|
||||
if(definition.kind==LogicNodeKind::Ecu)for(const auto& link:document.links)if(link.kind==MachineConnectionKind::Control&&link.a.part==definition.part)if(const auto target=document.find(link.b.part);target&&target->kind==MachinePartKind::Cylinder)node.ecu_shaft=machine_shaft_for(document,target->id);
|
||||
for(unsigned p=0;p<node.ports.size();++p){node.values[p]=invalid(node.ports[p],SignalValidity::Missing,0);if(node.ports[p].direction==PortDirection::Input)node.inputs.push_back(p);else node.outputs.push_back(p);}
|
||||
node.manual=definition.kind==LogicNodeKind::Switch?SignalValue::boolean(definition.boolean):definition.kind==LogicNodeKind::Button?SignalValue::boolean(false):SignalValue::number(definition.value);
|
||||
node.stored=definition.kind==LogicNodeKind::BooleanDelay?SignalValue::boolean(definition.boolean):SignalValue::number(definition.value);node.next=node.stored;
|
||||
by_id.emplace(definition.id,i);nodes.push_back(std::move(node));
|
||||
}
|
||||
const auto port_index=[&](std::size_t node,const std::string& key){const auto& ports=nodes[node].ports;for(std::size_t i=0;i<ports.size();++i)if(ports[i].key==key)return int(i);throw std::invalid_argument("Unknown compiled logic port");};
|
||||
for(const auto& link:graph.links){const auto to=by_id.at(link.to.node),from=by_id.at(link.from.node);nodes[to].sources[port_index(to,link.to.port)]={int(from),port_index(from,link.from.port)};}
|
||||
for(const auto id:logic_evaluation_order(graph))order.push_back(by_id.at(id));
|
||||
for(const auto& n:graph.nodes)if(n.kind==LogicNodeKind::Command)commands.push_back({n.id,n.part,n.command,{}});
|
||||
for(const auto& n:graph.nodes)if(n.kind==LogicNodeKind::Ecu)ecu_inputs.push_back({n.id,n.part,{},{},{},{},{}});
|
||||
}
|
||||
static SignalValue invalid(const MachinePortDescriptor& port,SignalValidity validity,double time){auto result=port.domain==PortDomain::Boolean?SignalValue::boolean(false,time):SignalValue::number(0,time);result.validity=validity;return result;}
|
||||
void inputs(Node& node,double time){for(const auto p:node.inputs){const auto source=node.sources[p];SignalValue value;
|
||||
if(source.node>=0)value=nodes[source.node].values[source.port];
|
||||
else if(std::holds_alternative<bool>(node.ports[p].default_value))value=SignalValue::boolean(std::get<bool>(node.ports[p].default_value),time);
|
||||
else if(std::holds_alternative<double>(node.ports[p].default_value))value=SignalValue::number(std::get<double>(node.ports[p].default_value),time);
|
||||
else value=invalid(node.ports[p],SignalValidity::Missing,time);
|
||||
if(value.validity==SignalValidity::Valid&&(!signal_value_valid_for(value,node.ports[p])||value.timestamp!=time)){value=invalid(node.ports[p],SignalValidity::Fault,time);node.problem="Input type, range or sample time does not match";}
|
||||
if(value.validity!=SignalValidity::Valid&&!node.problem)node.problem=value.validity==SignalValidity::Missing?"Input signal is missing":"Input signal is invalid";
|
||||
node.values[p]=value;
|
||||
}}
|
||||
void emit(Node& node,unsigned output,SignalValue value,double time){const auto p=node.outputs[output];value.timestamp=time;if(value.validity==SignalValidity::Valid&&!signal_value_valid_for(value,node.ports[p])){value=invalid(node.ports[p],SignalValidity::Fault,time);node.problem="Output type or declared range does not match";}if(value.validity!=SignalValidity::Valid&&!node.problem)node.problem=value.validity==SignalValidity::Missing?"Signal is missing":"Non-finite or invalid signal";node.values[p]=value;}
|
||||
void evaluate(double time,double dt){
|
||||
if(!std::isfinite(time)||!std::isfinite(dt)||dt<=0)throw std::invalid_argument("Logic requires a finite sample time and positive period");
|
||||
for(auto& node:nodes)node.problem=nullptr;
|
||||
// Memory outputs are visible before any combinational evaluation. Every
|
||||
// memory input is then sampled before any stored value is committed.
|
||||
for(std::size_t i=0;i<nodes.size();++i)if(logic_memory_node(graph.nodes[i].kind))emit(nodes[i],0,nodes[i].stored,time);
|
||||
for(const auto index:order){auto& node=nodes[index];const auto& n=graph.nodes[index];inputs(node,time);
|
||||
const auto number=[&](unsigned i){return std::get<double>(node.values[node.inputs[i]].value);};
|
||||
const auto bit=[&](unsigned i){return std::get<bool>(node.values[node.inputs[i]].value);};
|
||||
const auto numeric=[&](double value){emit(node,0,SignalValue::number(value,time),time);};
|
||||
const auto boolean=[&](bool value){emit(node,0,SignalValue::boolean(value,time),time);};
|
||||
if(n.kind==LogicNodeKind::Sensor){MachineSensorState sample;if(reader&&node.sensor_bound)sample=reader(n.sensor);for(unsigned i=0;i<node.outputs.size();++i){const auto valid=sample.id==n.sensor&&i<sample.count&&sample.outputs[i].timestamp==time;if(!valid)node.problem="Sensor reference or current sample is unavailable";auto value=valid?sample.outputs[i]:invalid(node.ports[node.outputs[i]],SignalValidity::Missing,time);emit(node,i,value,time);}continue;}
|
||||
if(n.kind==LogicNodeKind::Select){const auto& select=node.values[node.inputs[0]];if(select.validity!=SignalValidity::Valid){emit(node,0,invalid(node.ports[node.outputs[0]],select.validity,time),time);continue;}const auto selected=node.values[node.inputs[std::get<bool>(select.value)?2:1]];node.problem=selected.validity==SignalValidity::Valid?nullptr:selected.validity==SignalValidity::Missing?"Selected input is missing":"Selected input is invalid";emit(node,0,selected,time);continue;}
|
||||
SignalValidity validity=SignalValidity::Valid;for(const auto p:node.inputs){if(node.values[p].validity==SignalValidity::Fault){validity=SignalValidity::Fault;break;}if(node.values[p].validity==SignalValidity::Missing)validity=SignalValidity::Missing;}
|
||||
if(validity!=SignalValidity::Valid){for(unsigned o=0;o<node.outputs.size();++o)emit(node,o,invalid(node.ports[node.outputs[o]],validity,time),time);continue;}
|
||||
switch(n.kind){
|
||||
case LogicNodeKind::BooleanConstant:boolean(n.boolean);break;
|
||||
case LogicNodeKind::NumberConstant:numeric(n.value);break;
|
||||
case LogicNodeKind::Switch:case LogicNodeKind::Slider:case LogicNodeKind::NumberInput:
|
||||
while(!node.events.empty()){node.manual=node.events.front();node.events.pop_front();}emit(node,0,node.manual,time);break;
|
||||
case LogicNodeKind::Button:
|
||||
// One transition per boundary preserves a short press/release
|
||||
// pair and keeps repeated presses distinguishable by edges.
|
||||
if(!node.events.empty()){node.manual=node.events.front();node.events.pop_front();}emit(node,0,node.manual,time);break;
|
||||
case LogicNodeKind::And:case LogicNodeKind::Nand:{bool result=true;for(unsigned i=0;i<node.inputs.size();++i)result&=bit(i);boolean(n.kind==LogicNodeKind::Nand?!result:result);break;}
|
||||
case LogicNodeKind::Or:case LogicNodeKind::Nor:{bool result=false;for(unsigned i=0;i<node.inputs.size();++i)result|=bit(i);boolean(n.kind==LogicNodeKind::Nor?!result:result);break;}
|
||||
case LogicNodeKind::Xor:{bool result=false;for(unsigned i=0;i<node.inputs.size();++i)result^=bit(i);boolean(result);break;}
|
||||
case LogicNodeKind::Not:boolean(!bit(0));break;
|
||||
case LogicNodeKind::Greater:boolean(number(0)>number(1));break;
|
||||
case LogicNodeKind::Less:boolean(number(0)<number(1));break;
|
||||
case LogicNodeKind::Equal:boolean(std::abs(number(0)-number(1))<=n.tolerance);break;
|
||||
case LogicNodeKind::Window:boolean(number(0)>=n.low&&number(0)<=n.high);break;
|
||||
case LogicNodeKind::Hysteresis:
|
||||
if(n.below){if(number(0)<=n.low)node.hysteresis=true;else if(number(0)>=n.high)node.hysteresis=false;}
|
||||
else {if(number(0)>=n.high)node.hysteresis=true;else if(number(0)<=n.low)node.hysteresis=false;}
|
||||
boolean(node.hysteresis);break;
|
||||
case LogicNodeKind::Add:numeric(number(0)+number(1));break;
|
||||
case LogicNodeKind::Subtract:numeric(number(0)-number(1));break;
|
||||
case LogicNodeKind::Multiply:numeric(number(0)*number(1));break;
|
||||
case LogicNodeKind::Divide:
|
||||
if(number(1)==0){node.problem="Division by zero";emit(node,0,invalid(node.ports[node.outputs[0]],SignalValidity::Fault,time),time);}else numeric(number(0)/number(1));break;
|
||||
case LogicNodeKind::Minimum:numeric(std::min(number(0),number(1)));break;
|
||||
case LogicNodeKind::Maximum:numeric(std::max(number(0),number(1)));break;
|
||||
case LogicNodeKind::Absolute:numeric(std::abs(number(0)));break;
|
||||
case LogicNodeKind::Clamp:numeric(std::clamp(number(0),n.low,n.high));break;
|
||||
case LogicNodeKind::GainOffset:numeric(number(0)*n.gain+n.offset);break;
|
||||
case LogicNodeKind::MapRange:{auto f=(number(0)-n.input_low)/(n.input_high-n.input_low);if(n.clamp)f=std::clamp(f,0.,1.);numeric(std::lerp(n.output_low,n.output_high,f));break;}
|
||||
case LogicNodeKind::BooleanToNumber:numeric(bit(0)?n.true_value:n.false_value);break;
|
||||
case LogicNodeKind::Command:case LogicNodeKind::Ecu:case LogicNodeKind::Lamp:case LogicNodeKind::Readout:break;
|
||||
case LogicNodeKind::Sensor:case LogicNodeKind::Select:case LogicNodeKind::BooleanDelay:case LogicNodeKind::NumberDelay:break;
|
||||
}
|
||||
}
|
||||
for(std::size_t i=0;i<nodes.size();++i)if(logic_memory_node(graph.nodes[i].kind)){auto& node=nodes[i];inputs(node,time);node.next=node.values[node.inputs[0]];}
|
||||
for(std::size_t i=0;i<nodes.size();++i)if(logic_memory_node(graph.nodes[i].kind))nodes[i].stored=nodes[i].next;
|
||||
for(auto& command:commands)command.value=nodes[by_id.at(command.node)].values[0];
|
||||
for(auto& input:ecu_inputs){const auto index=by_id.at(input.node);auto& node=nodes[index];const auto& values=node.values;input.speed=values[0];input.angle=values[1];input.tps=input.map=input.iat=SignalValue{};
|
||||
if(graph.nodes[index].ecu_strategy==SensorEcuConfig::Strategy::AlphaN)input.tps=values[2];
|
||||
if(graph.nodes[index].ecu_strategy==SensorEcuConfig::Strategy::SpeedDensity){input.map=values[2];input.iat=values[3];}
|
||||
const auto crank=ecu_crank_sample(input.speed,input.angle,time);if(!crank)node.problem="ECU needs current crank channels from one physical pickup";else if(node.ecu_shaft&&crank->shaft!=node.ecu_shaft)node.problem="Crank pickup belongs to a different engine shaft";
|
||||
}
|
||||
}
|
||||
void manual(std::uint32_t id,SignalValue value){const auto found=by_id.find(id);if(found==by_id.end())throw std::invalid_argument("Unknown logic control");auto& node=nodes[found->second];const auto& definition=graph.nodes[found->second];const bool bit=definition.kind==LogicNodeKind::Switch||definition.kind==LogicNodeKind::Button;
|
||||
if(!bit&&definition.kind!=LogicNodeKind::Slider&&definition.kind!=LogicNodeKind::NumberInput)throw std::invalid_argument("This node is not a manual control");
|
||||
if(!signal_value_valid_for(value,node.ports[node.outputs[0]]))throw std::invalid_argument("Manual control type or range does not match its output");
|
||||
const auto& previous=node.events.empty()?node.manual:node.events.back();if(previous.value==value.value)return;
|
||||
// Numeric controls set the next boundary's value. Coalesce pointer
|
||||
// motion while paused; momentary button transitions retain their queue.
|
||||
if(definition.kind==LogicNodeKind::Slider||definition.kind==LogicNodeKind::NumberInput)node.events.clear();
|
||||
if(node.events.size()>=4096)throw std::invalid_argument("Too many pending logic control events");
|
||||
node.events.push_back(value);
|
||||
}
|
||||
};
|
||||
MachineLogicRuntime::MachineLogicRuntime(const MachineDocument& document,SensorReader reader):impl_(std::make_unique<Impl>(document,std::move(reader))){}
|
||||
MachineLogicRuntime::~MachineLogicRuntime()=default;
|
||||
void MachineLogicRuntime::evaluate(double time,double dt){impl_->evaluate(time,dt);}
|
||||
SignalValue MachineLogicRuntime::value(const LogicEndpoint& endpoint)const{const auto found=impl_->by_id.find(endpoint.node);if(found==impl_->by_id.end())throw std::out_of_range("Unknown logic node");const auto& node=impl_->nodes[found->second];for(std::size_t i=0;i<node.ports.size();++i)if(node.ports[i].key==endpoint.port)return node.values[i];throw std::out_of_range("Unknown logic port");}
|
||||
std::vector<LogicNodeState> MachineLogicRuntime::snapshot()const{std::vector<LogicNodeState> result;result.reserve(impl_->nodes.size());for(std::size_t i=0;i<impl_->nodes.size();++i)result.push_back({impl_->graph.nodes[i].id,impl_->nodes[i].values,impl_->nodes[i].problem?impl_->nodes[i].problem:""});return result;}
|
||||
const std::vector<LogicCommandState>& MachineLogicRuntime::commands()const{return impl_->commands;}
|
||||
const std::vector<LogicEcuInputs>& MachineLogicRuntime::ecu_inputs()const{return impl_->ecu_inputs;}
|
||||
void MachineLogicRuntime::set_manual(std::uint32_t node,bool value){impl_->manual(node,SignalValue::boolean(value));}
|
||||
void MachineLogicRuntime::set_manual(std::uint32_t node,double value){impl_->manual(node,SignalValue::number(value));}
|
||||
void MachineLogicRuntime::release_buttons(){for(std::size_t i=0;i<impl_->nodes.size();++i)if(impl_->graph.nodes[i].kind==LogicNodeKind::Button){auto& node=impl_->nodes[i];node.events.clear();node.events.push_back(SignalValue::boolean(false));}}
|
||||
void MachineLogicRuntime::apply_tuning(const MachineDocument& document){validate_machine_logic(document);if(!logic_topology_equal(impl_->graph,document.logic))throw std::invalid_argument("Logic topology, quantities, bindings, control ranges and initial memory require Build");for(std::size_t i=0;i<document.logic.nodes.size();++i){const auto& node=document.logic.nodes[i];if(node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu){const auto problem=logic_binding_problem(document,node);if(!problem.empty())throw std::invalid_argument(node.name+": "+problem);}if(logic_node_ports(node,&document)!=impl_->nodes[i].ports)throw std::invalid_argument("A changed port contract requires Build");}impl_->graph=document.logic;}
|
||||
}
|
||||
47
src/logic_serialization.cpp
Normal file
47
src/logic_serialization.cpp
Normal file
@@ -0,0 +1,47 @@
|
||||
#include "logic_serialization.hpp"
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace {
|
||||
void require(bool value,const std::string& message){if(!value)throw std::invalid_argument(message);}
|
||||
std::string text(const toml::table& table,const char* key,std::string fallback){if(!table.contains(key))return fallback;const auto value=table[key].value<std::string>();require(bool(value),std::string("Logic ")+key+" must be text");return *value;}
|
||||
std::uint32_t id(const toml::table& table,const char* key,std::uint32_t fallback,bool zero=true){if(!table.contains(key))return fallback;const auto value=table[key].value<std::int64_t>();require(value&&*value>=(zero?0:1)&&*value<1000000000,std::string("Invalid logic ")+key);return std::uint32_t(*value);}
|
||||
const char* reference_key(PressureReference reference){switch(reference){case PressureReference::None:return "none";case PressureReference::Absolute:return "absolute";case PressureReference::Gauge:return "gauge";case PressureReference::Differential:return "differential";}throw std::invalid_argument("Unknown pressure reference");}
|
||||
template<class T,class Key>T enumeration(const toml::table& table,const char* field,T fallback,int count,Key key){const auto name=text(table,field,key(fallback));for(int i=0;i<count;++i)if(name==key(T(i)))return T(i);throw std::invalid_argument(std::string("Unknown logic ")+field+": "+name);}
|
||||
using Numbers=std::vector<std::pair<const char*,double*>>;
|
||||
Numbers numbers(LogicNode& n){return {{"value",&n.value},{"minimum",&n.minimum},{"maximum",&n.maximum},{"low",&n.low},{"high",&n.high},{"tolerance",&n.tolerance},{"gain",&n.gain},{"offset",&n.offset},{"false_value",&n.false_value},{"true_value",&n.true_value},{"input_low",&n.input_low},{"input_high",&n.input_high},{"output_low",&n.output_low},{"output_high",&n.output_high}};}
|
||||
using Booleans=std::vector<std::pair<const char*,bool*>>;
|
||||
Booleans booleans(LogicNode& n){return {{"boolean",&n.boolean},{"initial",&n.initial},{"below",&n.below},{"clamp",&n.clamp}};}
|
||||
}
|
||||
LogicGraph read_logic_graph(const toml::table& machine){
|
||||
LogicGraph graph;if(!machine.contains("logic"))return graph;const auto logic=machine["logic"].as_table();require(logic,"Logic must be a table");
|
||||
if(logic->contains("nodes")){const auto nodes=(*logic)["nodes"].as_array();require(nodes&&nodes->size()<=2048,"Invalid logic node array");
|
||||
for(const auto& item:*nodes){const auto table=item.as_table();require(table,"Logic node must be a table");LogicNode n;n.id=id(*table,"id",0,false);require(n.id>0,"Logic node needs an ID");
|
||||
n.kind=enumeration(*table,"kind",n.kind,logic_node_kind_count,logic_node_key);n.name=text(*table,"name",logic_node_name(n.kind));
|
||||
n.quantity=enumeration(*table,"quantity",n.quantity,int(SignalQuantity::Inertia)+1,signal_quantity_key);n.output_quantity=enumeration(*table,"output_quantity",n.output_quantity,int(SignalQuantity::Inertia)+1,signal_quantity_key);
|
||||
n.pressure_reference=enumeration(*table,"pressure_reference",n.pressure_reference,4,reference_key);n.output_pressure_reference=enumeration(*table,"output_pressure_reference",n.output_pressure_reference,4,reference_key);
|
||||
n.command=enumeration(*table,"command",n.command,logic_command_count,logic_command_key);n.sensor_kind=enumeration(*table,"sensor_kind",n.sensor_kind,machine_sensor_kind_count,machine_sensor_key);
|
||||
n.part=id(*table,"part",0);n.sensor=id(*table,"sensor",0);n.binding_label=text(*table,"binding_label",{});n.input_count=id(*table,"input_count",2);
|
||||
n.ecu_strategy=sensor_ecu_strategy_from_key(text(*table,"ecu_strategy","ignition"));
|
||||
for(auto [key,target]:numbers(n))if(table->contains(key)){const auto value=(*table)[key].value<double>();require(bool(value),std::string("Logic ")+key+" must be numeric");*target=*value;}
|
||||
for(auto [key,target]:booleans(n))if(table->contains(key)){const auto value=(*table)[key].value<bool>();require(bool(value),std::string("Logic ")+key+" must be Boolean");*target=*value;}
|
||||
if(table->contains("position")){const auto position=(*table)["position"].as_array();require(position&&position->size()==2,"Logic position needs two coordinates");const auto x=(*position)[0].value<double>(),y=(*position)[1].value<double>();require(x&&y,"Logic position must be numeric");n.position={float(*x),float(*y)};}
|
||||
graph.next_id=std::max(graph.next_id,n.id+1);graph.nodes.push_back(std::move(n));
|
||||
}
|
||||
}
|
||||
if(logic->contains("wires")){const auto wires=(*logic)["wires"].as_array();require(wires&&wires->size()<=8192,"Invalid logic wire array");for(const auto& item:*wires){const auto t=item.as_table();require(t,"Logic wire must be a table");graph.links.push_back({{id(*t,"from_node",0,false),text(*t,"from_port",{})},{id(*t,"to_node",0,false),text(*t,"to_port",{})}});}}
|
||||
const auto next=id(*logic,"next_id",graph.next_id,false);require(next>=graph.next_id,"Invalid next logic ID");graph.next_id=next;validate_logic_graph(graph);return graph;
|
||||
}
|
||||
toml::table write_logic_graph(const LogicGraph& graph){
|
||||
validate_logic_graph(graph);toml::table result{{"next_id",int64_t(graph.next_id)}};toml::array nodes,wires;
|
||||
for(auto n:graph.nodes){toml::table t{{"id",int64_t(n.id)},{"kind",logic_node_key(n.kind)},{"name",n.name},{"position",toml::array{double(n.position.x),double(n.position.y)}},{"quantity",signal_quantity_key(n.quantity)},{"pressure_reference",reference_key(n.pressure_reference)},{"output_quantity",signal_quantity_key(n.output_quantity)},{"output_pressure_reference",reference_key(n.output_pressure_reference)},{"sensor",int64_t(n.sensor)},{"sensor_kind",machine_sensor_key(n.sensor_kind)},{"part",int64_t(n.part)},{"command",logic_command_key(n.command)},{"binding_label",n.binding_label},{"input_count",int64_t(n.input_count)}};
|
||||
for(auto [key,value]:numbers(n))t.insert(key,*value);
|
||||
t.insert("ecu_strategy",sensor_ecu_strategy_key(n.ecu_strategy));
|
||||
for(auto [key,value]:booleans(n))t.insert(key,*value);
|
||||
nodes.push_back(std::move(t));
|
||||
}
|
||||
for(const auto& wire:graph.links)wires.push_back(toml::table{{"from_node",int64_t(wire.from.node)},{"from_port",wire.from.port},{"to_node",int64_t(wire.to.node)},{"to_port",wire.to.port}});
|
||||
result.insert("nodes",std::move(nodes));result.insert("wires",std::move(wires));return result;
|
||||
}
|
||||
}
|
||||
7
src/logic_serialization.hpp
Normal file
7
src/logic_serialization.hpp
Normal file
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include "FluidSim/MachineLogic.hpp"
|
||||
#include <toml.hpp>
|
||||
namespace fluidsim::port {
|
||||
LogicGraph read_logic_graph(const toml::table& machine);
|
||||
toml::table write_logic_graph(const LogicGraph&);
|
||||
}
|
||||
621
src/machine.cpp
Normal file
621
src/machine.cpp
Normal file
@@ -0,0 +1,621 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "logic_serialization.hpp"
|
||||
#include "logic_assembly.hpp"
|
||||
#include "FluidSim/Constants.hpp"
|
||||
#include "FluidSim/Fluid.hpp"
|
||||
#include "FluidSim/PipeProfile.hpp"
|
||||
#include "FluidSim/Solver.hpp"
|
||||
#include <toml.hpp>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace machine_math {
|
||||
Vec3 add(Vec3 a,Vec3 b){return {a.x+b.x,a.y+b.y,a.z+b.z};}
|
||||
Vec3 subtract(Vec3 a,Vec3 b){return {a.x-b.x,a.y-b.y,a.z-b.z};}
|
||||
Vec3 multiply(Vec3 a,float s){return {a.x*s,a.y*s,a.z*s};}
|
||||
float dot(Vec3 a,Vec3 b){return a.x*b.x+a.y*b.y+a.z*b.z;}
|
||||
Vec3 cross(Vec3 a,Vec3 b){return {a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x};}
|
||||
float length(Vec3 a){return std::sqrt(dot(a,a));}
|
||||
Vec3 unit(Vec3 a){const float l=length(a);return l>1e-9f?multiply(a,1/l):Vec3{1,0,0};}
|
||||
Vec3 rotate(Vec3 v,Vec3 r){
|
||||
r=multiply(r,pi/180);float c=std::cos(r.x),s=std::sin(r.x);v={v.x,c*v.y-s*v.z,s*v.y+c*v.z};
|
||||
c=std::cos(r.y);s=std::sin(r.y);v={c*v.x+s*v.z,v.y,-s*v.x+c*v.z};
|
||||
c=std::cos(r.z);s=std::sin(r.z);return {c*v.x-s*v.y,s*v.x+c*v.y,v.z};
|
||||
}
|
||||
Vec3 inverse_rotate(Vec3 v,Vec3 r){
|
||||
r=multiply(r,-pi/180);float c=std::cos(r.z),s=std::sin(r.z);v={c*v.x-s*v.y,s*v.x+c*v.y,v.z};
|
||||
c=std::cos(r.y);s=std::sin(r.y);v={c*v.x+s*v.z,v.y,-s*v.x+c*v.z};
|
||||
c=std::cos(r.x);s=std::sin(r.x);return {v.x,c*v.y-s*v.z,s*v.y+c*v.z};
|
||||
}
|
||||
Vec3 rotate_euler_world(Vec3 e,unsigned axis,float degrees){
|
||||
const Vec3 a=axis==0?Vec3{1,0,0}:axis==1?Vec3{0,1,0}:Vec3{0,0,1};const float c=std::cos(degrees*pi/180),s=std::sin(degrees*pi/180);
|
||||
const auto turn=[&](Vec3 v){return add(add(multiply(v,c),multiply(cross(a,v),s)),multiply(a,dot(a,v)*(1-c)));};
|
||||
const auto x=turn(rotate({1,0,0},e)),y=turn(rotate({0,1,0},e)),z=turn(rotate({0,0,1},e));const float pitch=std::asin(std::clamp(-x.z,-1.f,1.f));
|
||||
if(std::abs(std::cos(pitch))<1e-5f)return {0,pitch*180/pi,std::atan2(-y.x,y.y)*180/pi};
|
||||
return {std::atan2(y.z,z.z)*180/pi,pitch*180/pi,std::atan2(x.y,x.x)*180/pi};
|
||||
}
|
||||
}
|
||||
using namespace machine_math;
|
||||
namespace {
|
||||
constexpr const char* keys[]{"pipe","chamber","junction","throttle","check_valve","cylinder","piston_pump","reservoir","cap","carburetor","ecu","shaft","flywheel","drive_motor","brake"};
|
||||
constexpr const char* names[]{"Pipe / reducer","Chamber / tank","Junction","Throttle valve","Check valve","Engine cylinder","Piston pump","Pressure / atmosphere","End cap","Carburetor","ECU / fuel injection","Shaft / crankshaft","Flywheel","Drive motor","Brake / load"};
|
||||
static_assert(std::size(keys)==machine_part_kind_count&&std::size(names)==machine_part_kind_count,"Every machine part needs a stable key and name");
|
||||
void require(bool condition,const std::string& message){if(!condition)throw std::invalid_argument(message);}
|
||||
bool finite(Vec3 v){return std::isfinite(v.x)&&std::isfinite(v.y)&&std::isfinite(v.z);}
|
||||
bool range(float v,float lo,float hi){return std::isfinite(v)&&v>=lo&&v<=hi;}
|
||||
bool cylinder_kind(MachinePartKind k){return k==MachinePartKind::Cylinder||k==MachinePartKind::PistonPump;}
|
||||
bool occupied(const MachineDocument& d,MachinePort e){return std::any_of(d.links.begin(),d.links.end(),[&](auto& l){return l.a==e||l.b==e;});}
|
||||
toml::array xyz(Vec3 v){return toml::array{double(v.x),double(v.y),double(v.z)};}
|
||||
Vec3 read_xyz(const toml::node_view<const toml::node>& n,Vec3 fallback={}){
|
||||
if(!n)return fallback;
|
||||
auto a=n.as_array();require(a&&a->size()==3,"Expected a vector of three numbers");
|
||||
Vec3 v;float* values[]{&v.x,&v.y,&v.z};
|
||||
for(int i=0;i<3;++i){const auto value=(*a)[i].value<double>();require(bool(value),"Vector needs numeric coordinates");*values[i]=float(*value);}
|
||||
return v;
|
||||
}
|
||||
float read_number(const toml::table& t,const char* key,float fallback){
|
||||
const auto node=t[key];if(!node)return fallback;auto n=node.value<double>();require(bool(n),std::string(key)+" must be numeric");return float(*n);
|
||||
}
|
||||
unsigned read_id(const toml::node_view<const toml::node>& node){auto n=node.value<std::int64_t>();require(n&&*n>0&&*n<1000000000,"Invalid part ID");return unsigned(*n);}
|
||||
MachinePartKind read_kind(const std::string& k){for(int i=0;i<machine_part_kind_count;++i)if(k==keys[i])return MachinePartKind(i);throw std::invalid_argument("Unknown machine part: "+k);}
|
||||
using Numbers=std::vector<std::pair<const char*,float*>>;
|
||||
Numbers ecu_values(EcuConfig& e){return {{"idle_rpm",&e.idle_rpm},{"idle_base_opening",&e.idle_base_opening},{"idle_max_opening",&e.idle_max_opening},{"idle_kp",&e.idle_kp},{"idle_ki",&e.idle_ki},{"throttle_time_constant",&e.throttle_time_constant},{"throttle_rate",&e.throttle_rate},{"throttle_closing_time_constant",&e.throttle_closing_time_constant},{"throttle_closing_rate",&e.throttle_closing_rate},{"rev_limit_rpm",&e.rev_limit_rpm},{"rev_resume_rpm",&e.rev_resume_rpm},{"rev_limit_cut_time",&e.rev_limit_cut_time},{"overrun_cut_rpm",&e.overrun_cut_rpm},{"overrun_resume_rpm",&e.overrun_resume_rpm},{"idle_spark_gain",&e.idle_spark_gain},{"idle_minimum_advance",&e.idle_minimum_advance},{"stoichiometric_afr",&e.stoichiometric_afr},{"power_afr",&e.power_afr}};}
|
||||
Numbers carb_values(CarburetorConfig& c){return {{"fuel_density",&c.fuel_density},{"pilot_jet_diameter",&c.pilot_jet_diameter},{"main_jet_diameter",&c.main_jet_diameter},{"needle_jet_diameter",&c.needle_jet_diameter},{"needle_low_scale",&c.needle_low_scale},{"needle_mid_scale",&c.needle_mid_scale},{"needle_off_idle_scale",&c.needle_off_idle_scale},{"needle_transition_scale",&c.needle_transition_scale},{"needle_tip_scale",&c.needle_tip_scale},{"needle_quarter_scale",&c.needle_quarter_scale},{"needle_upper_scale",&c.needle_upper_scale},{"pilot_transition_scale",&c.pilot_transition_scale},{"pilot_tip_scale",&c.pilot_tip_scale},{"pilot_discharge_coefficient",&c.pilot_discharge_coefficient},{"main_discharge_coefficient",&c.main_discharge_coefficient},{"bowl_head_pressure",&c.bowl_head_pressure},{"film_fraction",&c.film_fraction},{"film_time_constant",&c.film_time_constant},{"bowl_capacity",&c.bowl_capacity},{"bowl_refill_rate",&c.bowl_refill_rate},{"overrun_rpm",&c.overrun_rpm},{"overrun_exit_rpm",&c.overrun_exit_rpm}};}
|
||||
Numbers valve_values(FourStrokeCylinder::ValveDynamics& v){return {{"mass_kg",&v.mass_kg},{"spring_rate",&v.spring_rate_n_per_m},{"damping",&v.damping_n_s_per_m},{"seat_force",&v.seat_force_n},{"maximum_lift",&v.maximum_lift_m},{"seat_restitution",&v.seat_restitution},{"cam_restitution",&v.cam_restitution},{"stop_restitution",&v.stop_restitution}};}
|
||||
void read_numbers(const toml::table& t,const Numbers& numbers){for(auto [key,value]:numbers)*value=read_number(t,key,*value);}
|
||||
toml::table write_numbers(const Numbers& numbers){toml::table t;for(auto [key,value]:numbers)t.insert(key,double(*value));return t;}
|
||||
FourStrokeCylinder::CamProfile read_cam(const toml::array& points){FourStrokeCylinder::CamProfile profile;require(points.size()<=2048,"Too many cam points");for(auto& point:points){auto pair=point.as_array();require(pair&&pair->size()==2,"Cam point needs angle and lift");auto angle=(*pair)[0].value<double>(),lift=(*pair)[1].value<double>();require(angle&&lift,"Cam point must be numeric");profile.points.push_back({float(*angle),float(*lift)});}return profile;}
|
||||
toml::array write_cam(const FourStrokeCylinder::CamProfile& c){toml::array points;for(auto p:c.points)points.push_back(toml::array{double(p.angle_degrees),double(p.lift_metres)});return points;}
|
||||
}
|
||||
const char* machine_part_name(MachinePartKind k){require(int(k)>=0&&int(k)<machine_part_kind_count,"Unknown machine part kind");return names[int(k)];}
|
||||
const char* machine_part_key(MachinePartKind k){require(int(k)>=0&&int(k)<machine_part_kind_count,"Unknown machine part kind");return keys[int(k)];}
|
||||
SensorEcuConfig machine_sensor_ecu_config(const MachinePart& part){
|
||||
SensorEcuConfig config;config.strategy=part.sensor_ecu.strategy;config.ignition_map=part.ecu.ignition_map;config.rev_limit_enabled=part.ecu.rev_limit_enabled;config.rev_limit_rpm=part.ecu.rev_limit_rpm;config.rev_resume_rpm=part.ecu.rev_resume_rpm;
|
||||
const auto& c=part.sensor_ecu;config.fuel_rpm.assign(c.fuel_rpm.begin(),c.fuel_rpm.end());config.fuel_tps.assign(c.fuel_tps.begin(),c.fuel_tps.end());config.fuel_per_cycle.assign(c.fuel_per_cycle.begin(),c.fuel_per_cycle.end());
|
||||
config.displacement=c.displacement;config.air_gas_constant=c.air_gas_constant;config.target_afr=c.target_afr;config.ve_rpm.assign(c.ve_rpm.begin(),c.ve_rpm.end());config.ve_pressure.assign(c.ve_pressure.begin(),c.ve_pressure.end());config.volumetric_efficiency.assign(c.volumetric_efficiency.begin(),c.volumetric_efficiency.end());return config;
|
||||
}
|
||||
const char* machine_connection_name(MachineConnectionKind k){static const char* values[]{"Orifice","Inertance","Characteristic","Legacy Rusanov","ECU control wire","Mechanical attachment"};return values[int(k)];}
|
||||
const char* machine_connection_key(MachineConnectionKind kind){switch(kind){case MachineConnectionKind::Orifice:return "orifice";case MachineConnectionKind::Inertance:return "inertance";case MachineConnectionKind::Characteristic:return "characteristic";case MachineConnectionKind::Legacy:return "legacy_rusanov";case MachineConnectionKind::Control:return "ecu_assignment";case MachineConnectionKind::Mechanical:return "mechanical";}throw std::invalid_argument("Unknown connection method");}
|
||||
MachinePart* MachineDocument::find(std::uint32_t id){for(auto& p:parts)if(p.id==id)return &p;return nullptr;}
|
||||
const MachinePart* MachineDocument::find(std::uint32_t id)const{for(auto& p:parts)if(p.id==id)return &p;return nullptr;}
|
||||
MachinePart& MachineDocument::add(MachinePartKind kind,Vec3 pos){
|
||||
MachinePart p;p.kind=kind;p.id=next_id++;p.name=std::string(machine_part_name(kind))+" "+std::to_string(p.id);p.position=pos;
|
||||
if(kind==MachinePartKind::Throttle||kind==MachinePartKind::CheckValve||kind==MachinePartKind::Carburetor)p.size={.04f,.05f,.05f};
|
||||
if(kind==MachinePartKind::Junction){p.size={.06f,.06f,.06f};p.junction_method=1;}
|
||||
if(kind==MachinePartKind::Ecu){p.size={.10f,.025f,.075f};p.ecu.rev_limit_cut_time=.02f;}
|
||||
if(kind==MachinePartKind::Reservoir||kind==MachinePartKind::Cap)p.size={.04f,.04f,.04f};
|
||||
if(kind==MachinePartKind::Shaft){p.size={.25f,.035f,.035f};if(!primary_shaft)primary_shaft=p.id;}
|
||||
if(kind==MachinePartKind::Flywheel)p.size={.025f,.16f,.16f};
|
||||
if(kind==MachinePartKind::DriveMotor)p.size={.12f,.09f,.09f};
|
||||
if(kind==MachinePartKind::Brake)p.size={.07f,.10f,.10f};
|
||||
parts.push_back(std::move(p));return parts.back();
|
||||
}
|
||||
void MachineDocument::remove(std::uint32_t id){
|
||||
// Keep sensor entities and their intended host labels. Zero is an explicit
|
||||
// unresolved binding, so later part insertion cannot silently repair it.
|
||||
for(auto& sensor:sensors)for(auto* tap:{&sensor.target,&sensor.reference})if(tap->part==id){if(const auto host=find(id))tap->label=host->name;tap->part=0;}
|
||||
for(auto& node:logic.nodes)if((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu)&&node.part==id){if(const auto host=find(id))node.binding_label=host->name;node.part=0;}
|
||||
for(auto& part:parts)if(part.injector&&part.injector->ecu==id){if(const auto host=find(id))part.injector->ecu_label=host->name;part.injector->ecu=0;}
|
||||
if(primary_shaft==id)primary_shaft=0;
|
||||
std::erase_if(links,[&](const auto& l){return l.a.part==id||l.b.part==id;});std::erase_if(probes,[&](const auto& p){return p.part==id;});std::erase_if(parts,[&](auto& p){return p.id==id;});
|
||||
}
|
||||
const char* machine_quantity_name(MachineQuantity q){static const char* names[]{"Pressure","Temperature","Mass flow","Bulk AFR","Lambda","Charge AFR (pre-ignition)","Shaft RPM","Shaft torque","Shaft power"};return names[int(q)];}
|
||||
const char* machine_reading_state_name(MachineReadingState state){switch(state){case MachineReadingState::Valid:return "Valid";case MachineReadingState::NoFuel:return "No fuel detected";case MachineReadingState::LeanLimit:return "Very lean / trace fuel (>1000 AFR)";case MachineReadingState::FuelCut:return "Injection fuel cut";case MachineReadingState::AwaitingCharge:return "Awaiting pre-ignition charge";}return "Unavailable";}
|
||||
const char* machine_quantity_unit(MachineQuantity q){static const char* units[]{"kPa abs","K","g/s","AFR","lambda","AFR","RPM","Nm","kW"};return units[int(q)];}
|
||||
float machine_quantity_scale(MachineQuantity q){return q==MachineQuantity::Pressure||q==MachineQuantity::Power?.001f:q==MachineQuantity::MassFlow?1000.f:1.f;}
|
||||
bool machine_quantity_supported(const MachinePart& part,MachineQuantity q){
|
||||
const auto kind=part.kind;
|
||||
if(q>=MachineQuantity::Rpm)return machine_rotational_part(kind)||kind==MachinePartKind::Cylinder||kind==MachinePartKind::PistonPump;
|
||||
if(machine_rotational_part(kind))return false;
|
||||
if(q==MachineQuantity::ChargeAfr)return kind==MachinePartKind::Cylinder;
|
||||
if(kind==MachinePartKind::Ecu||kind==MachinePartKind::Cap)return false;
|
||||
if(kind==MachinePartKind::Junction)return part.junction_method==0;
|
||||
if(q==MachineQuantity::Pressure||q==MachineQuantity::Temperature)return true;
|
||||
if(q==MachineQuantity::MassFlow)return kind==MachinePartKind::Pipe||kind==MachinePartKind::Cylinder||kind==MachinePartKind::PistonPump||kind==MachinePartKind::Chamber||kind==MachinePartKind::Throttle||kind==MachinePartKind::CheckValve||kind==MachinePartKind::Carburetor;
|
||||
return kind==MachinePartKind::Pipe||kind==MachinePartKind::Cylinder||kind==MachinePartKind::Carburetor||kind==MachinePartKind::Chamber;
|
||||
}
|
||||
void MachineDocument::disconnect(MachinePort p){std::erase_if(links,[&](auto& l){return l.a==p||l.b==p;});}
|
||||
Vec3 machine_world_point(const MachinePart& p,Vec3 local){return add(p.position,rotate(local,p.rotation));}
|
||||
Vec3 machine_local_point(const MachinePart& p,Vec3 world){return inverse_rotate(subtract(world,p.position),p.rotation);}
|
||||
float machine_pipe_length(const MachinePart& p){float l=0;for(std::size_t i=1;i<p.points.size();++i)l+=length(subtract(p.points[i],p.points[i-1]));return l;}
|
||||
std::vector<float> machine_pipe_diameters(const MachinePart& p){
|
||||
if(!p.point_diameters.empty()){require(p.point_diameters.size()==p.points.size(),"Pipe needs one diameter per point");return p.point_diameters;}
|
||||
std::vector<float> result;result.reserve(p.points.size());const float total=machine_pipe_length(p);float at=0;
|
||||
for(std::size_t i=0;i<p.points.size();++i){if(i)at+=length(subtract(p.points[i],p.points[i-1]));result.push_back(std::lerp(p.diameter,p.outlet_diameter,total>0?at/total:0.f));}return result;
|
||||
}
|
||||
std::vector<PipeSection> machine_pipe_sections(const MachinePart& p){
|
||||
const auto diameters=machine_pipe_diameters(p);std::vector<PipeSection> result;
|
||||
for(std::size_t i=1;i<p.points.size();++i)result.push_back({length(subtract(p.points[i],p.points[i-1])),diameters[i-1],diameters[i]});
|
||||
return result;
|
||||
}
|
||||
float machine_pipe_diameter(const MachinePart& p,float distance){
|
||||
const auto legacy=p.point_diameters.empty()?machine_pipe_diameters(p):std::vector<float>{};const auto& diameters=p.point_diameters.empty()?legacy:p.point_diameters;require(!diameters.empty(),"Pipe has no points");distance=std::max(0.f,distance);
|
||||
for(std::size_t i=1;i<p.points.size();++i){const float span=length(subtract(p.points[i],p.points[i-1]));if(distance<=span)return std::lerp(diameters[i-1],diameters[i],span>0?distance/span:0.f);distance-=span;}return diameters.back();
|
||||
}
|
||||
void machine_insert_pipe_point(MachinePart& p,std::size_t segment,Vec3 point){
|
||||
require(p.kind==MachinePartKind::Pipe&&segment+1<p.points.size()&&p.points.size()<128,"Invalid pipe point insertion");
|
||||
auto diameters=machine_pipe_diameters(p);const auto edge=subtract(p.points[segment+1],p.points[segment]);const float squared=dot(edge,edge);require(squared>0,"Invalid pipe segment");
|
||||
const float t=std::clamp(dot(subtract(point,p.points[segment]),edge)/squared,0.f,1.f);
|
||||
diameters.insert(diameters.begin()+segment+1,std::lerp(diameters[segment],diameters[segment+1],t));
|
||||
p.points.insert(p.points.begin()+segment+1,point);p.point_diameters=std::move(diameters);
|
||||
}
|
||||
void machine_remove_pipe_point(MachinePart& p,std::size_t point){
|
||||
require(p.kind==MachinePartKind::Pipe&&point>0&&point+1<p.points.size(),"Only interior pipe points can be removed");
|
||||
auto diameters=machine_pipe_diameters(p);p.points.erase(p.points.begin()+point);diameters.erase(diameters.begin()+point);p.point_diameters=std::move(diameters);
|
||||
}
|
||||
std::vector<Vec3> machine_pipe_path(const MachinePart& p){std::vector<Vec3> v;for(auto point:p.points)v.push_back(machine_world_point(p,point));return v;}
|
||||
Vec3 machine_part_size(const MachinePart& p){
|
||||
if(cylinder_kind(p.kind))return {p.cylinder.bore,p.cylinder.stroke*p.cylinder.compression/(p.cylinder.compression-1),p.cylinder.bore};
|
||||
return p.size;
|
||||
}
|
||||
float machine_part_volume(const MachinePart& p){
|
||||
if(machine_rotational_part(p.kind))return 0;
|
||||
if(p.kind==MachinePartKind::Pipe){double volume=0;for(const auto& s:machine_pipe_sections(p))volume+=double(pi)/12*s.length*(double(s.inlet_diameter)*s.inlet_diameter+double(s.inlet_diameter)*s.outlet_diameter+double(s.outlet_diameter)*s.outlet_diameter);return float(volume);}
|
||||
if(cylinder_kind(p.kind))return pi*.25f*p.cylinder.bore*p.cylinder.bore*machine_part_size(p).y;
|
||||
if(p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::Throttle)return pi*.25f*p.size.x*p.size.y*p.size.z;
|
||||
if((p.kind==MachinePartKind::Junction&&p.junction_method)||p.kind==MachinePartKind::Cap||p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Ecu)return 0;
|
||||
require(p.kind==MachinePartKind::Chamber||p.kind==MachinePartKind::Junction||p.kind==MachinePartKind::CheckValve,"Gas storage is not declared for this part kind");
|
||||
return p.size.x*p.size.y*p.size.z;
|
||||
}
|
||||
unsigned machine_port_count(const MachinePart& p){
|
||||
if(p.kind==MachinePartKind::Junction&&p.junction_method)return 1;
|
||||
switch(p.kind){case MachinePartKind::Pipe:case MachinePartKind::CheckValve:return 2;
|
||||
case MachinePartKind::PistonPump:return 3;
|
||||
case MachinePartKind::Cylinder:return 4;
|
||||
case MachinePartKind::Throttle:case MachinePartKind::Carburetor:return 3;
|
||||
case MachinePartKind::Chamber:case MachinePartKind::Junction:return p.port_coordinates.empty()?6:unsigned(p.port_coordinates.size());
|
||||
case MachinePartKind::Reservoir:case MachinePartKind::Cap:case MachinePartKind::Ecu:case MachinePartKind::Shaft:case MachinePartKind::Flywheel:case MachinePartKind::DriveMotor:case MachinePartKind::Brake:return 1;}
|
||||
throw std::invalid_argument("Port definitions are not implemented for this part kind");
|
||||
}
|
||||
bool machine_control_port(const MachinePart& p,unsigned port){return machine_port_descriptor(p,port).domain==PortDomain::EcuAssignment;}
|
||||
bool machine_port_available(const MachineDocument& d,MachinePort port){const auto p=d.find(port.part);return p&&port.port<machine_port_count(*p)&&(machine_port_descriptor(*p,port.port).cardinality==PortCardinality::Multiple||!occupied(d,port));}
|
||||
std::string machine_port_name(const MachinePart& p,unsigned port){return machine_port_descriptor(p,port).name;}
|
||||
Vec3 machine_local_port(const MachinePart& p,unsigned port){
|
||||
if(port>=machine_port_count(p))throw std::invalid_argument("Invalid machine port");
|
||||
if(machine_port_descriptor(p,port).domain==PortDomain::Rotational){if(cylinder_kind(p.kind))return {0,-machine_part_size(p).y*.5f-.025f,0};return {p.kind==MachinePartKind::Shaft?p.size.x*.5f:-p.size.x*.5f,0,0};}
|
||||
if(p.kind==MachinePartKind::Pipe)return port?p.points.back():p.points.front();
|
||||
if(machine_control_port(p,port))return p.kind==MachinePartKind::Ecu?Vec3{p.size.x*.5f,0,0}:Vec3{0,machine_part_size(p).y*.5f+.025f,0};
|
||||
if(p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Cap||(p.kind==MachinePartKind::Junction&&p.junction_method))return {};
|
||||
const auto s=machine_part_size(p);
|
||||
if(port<p.port_coordinates.size()){const auto v=p.port_coordinates[port];return {v.x*s.x,v.y*s.y,v.z*s.z};}
|
||||
if(cylinder_kind(p.kind))return {(port?1:-1)*p.cylinder.bore*.23f,s.y*.5f,0};
|
||||
switch(port){case 0:return {-s.x*.5f,0,0};case 1:return {s.x*.5f,0,0};case 2:return {0,-s.y*.5f,0};case 3:return {0,s.y*.5f,0};case 4:return {0,0,-s.z*.5f};default:return {0,0,s.z*.5f};}
|
||||
}
|
||||
Vec3 machine_port_position(const MachinePart& p,unsigned port){return machine_world_point(p,machine_local_port(p,port));}
|
||||
void MachineDocument::connect(MachinePort a,MachinePort b,MachineConnectionKind kind){
|
||||
auto pa=find(a.part),pb=find(b.part);require(pa&&pb&&a.part!=b.part,"Choose ports on two different parts");
|
||||
require(a.port<machine_port_count(*pa)&&b.port<machine_port_count(*pb),"Invalid port");
|
||||
const auto compatible=port_compatibility(machine_port_descriptor(*pa,a.port),machine_port_descriptor(*pb,b.port));require(compatible.allowed,compatible.message);
|
||||
machine_connection_key(kind);
|
||||
if(machine_port_descriptor(*pa,a.port).domain==PortDomain::Rotational){
|
||||
if(pa->kind!=MachinePartKind::Shaft){std::swap(a,b);std::swap(pa,pb);}
|
||||
require(pa->kind==MachinePartKind::Shaft&&pb->kind!=MachinePartKind::Shaft,"Attach a component to one shaft; use a coupling between shafts");
|
||||
require(machine_port_available(*this,b),"This component already has a shaft");links.push_back({a,b,MachineConnectionKind::Mechanical});return;
|
||||
}
|
||||
|
||||
|
||||
if(machine_control_port(*pa,a.port)||machine_control_port(*pb,b.port)){
|
||||
if(pa->kind!=MachinePartKind::Ecu){std::swap(a,b);std::swap(pa,pb);}
|
||||
require(pa->kind==MachinePartKind::Ecu&&pb->kind!=MachinePartKind::Ecu&&machine_control_port(*pb,b.port),"Connect an ECU output to a cylinder, throttle, or carburetor ECU input");
|
||||
require(machine_port_available(*this,b),"This part already has an ECU assigned");links.push_back({a,b,MachineConnectionKind::Control});return;
|
||||
}
|
||||
require(int(kind)>=0&&int(kind)<4,"Gas ports require a gas connection method");
|
||||
if(pa->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(pa,pb);}
|
||||
require(pa->kind==MachinePartKind::Pipe,"Connect air parts using a pipe");
|
||||
require(!occupied(*this,a),"Start with a free pipe endpoint");
|
||||
if(!machine_port_available(*this,b)){
|
||||
require(pb->kind==MachinePartKind::Pipe,"Use a junction to branch this occupied component port");
|
||||
const auto old=std::find_if(links.begin(),links.end(),[&](auto& l){return l.a==b||l.b==b;});require(old!=links.end(),"Invalid occupied port");
|
||||
const auto other=old->a==b?old->b:old->a;require(find(other.part)->kind==MachinePartKind::Pipe,"Branch at a junction or at a pipe-to-pipe connection");
|
||||
const auto location=machine_port_position(*pb,b.port);auto proposed=pa->points;proposed[a.port?proposed.size()-1:0]=machine_local_point(*pa,location);
|
||||
for(std::size_t i=1;i<proposed.size();++i)require(length(subtract(proposed[i],proposed[i-1]))>=.001f,"Connection would collapse a pipe segment");
|
||||
pa->points=std::move(proposed);const auto old_kind=old->kind;const float loss=old->loss;links.erase(old);
|
||||
auto& node=add(MachinePartKind::Junction,location);node.junction_method=old_kind==MachineConnectionKind::Legacy?2:1;node.turning_loss=loss;const auto node_id=node.id;
|
||||
links.push_back({b,{node_id,0}});links.push_back({other,{node_id,0}});links.push_back({a,{node_id,0}});return;
|
||||
}
|
||||
require(pa->kind==MachinePartKind::Pipe||pb->kind==MachinePartKind::Pipe,"Connect parts using a pipe");
|
||||
if(pa->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(pa,pb);}
|
||||
if(pa->kind!=MachinePartKind::Pipe||pb->kind!=MachinePartKind::Pipe)
|
||||
require(kind==MachineConnectionKind::Orifice||kind==MachineConnectionKind::Inertance,"Use an orifice or inertance for a component port");
|
||||
else if(kind==MachineConnectionKind::Inertance)throw std::invalid_argument("Pipe-to-pipe connections use an ideal junction");
|
||||
auto proposed=pa->points;proposed[a.port?proposed.size()-1:0]=machine_local_point(*pa,machine_port_position(*pb,b.port));
|
||||
for(std::size_t i=1;i<proposed.size();++i)require(length(subtract(proposed[i],proposed[i-1]))>=.001f,"Connection collapses a pipe segment; move the pipe first");
|
||||
links.push_back({a,b,kind});pa->points=std::move(proposed);
|
||||
}
|
||||
void MachineDocument::sync_connections(std::uint32_t moved){
|
||||
for(auto& l:links){if(machine_link_domain(*this,l)!=PortDomain::Gas||(moved&&l.a.part!=moved&&l.b.part!=moved))continue;auto a=find(l.a.part),b=find(l.b.part);if(!a||!b)continue;
|
||||
if(a->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(l.a,l.b);}
|
||||
// Non-pipe parts anchor the endpoint. For two pipes, the moved pipe drives its neighbor.
|
||||
if(b->kind==MachinePartKind::Pipe&&a->id==moved){b->points[l.b.port?b->points.size()-1:0]=machine_local_point(*b,machine_port_position(*a,l.a.port));}
|
||||
else a->points[l.a.port?a->points.size()-1:0]=machine_local_point(*a,machine_port_position(*b,l.b.port));
|
||||
}
|
||||
}
|
||||
void MachineDocument::transform_selection(const std::vector<std::uint32_t>& ids,Vec3 pivot,Vec3 translation,unsigned axis,float degrees){
|
||||
const Vec3 r=axis==0?Vec3{degrees,0,0}:axis==1?Vec3{0,degrees,0}:Vec3{0,0,degrees};
|
||||
for(auto id:ids)if(auto p=find(id)){p->position=machine_math::add(machine_math::add(pivot,rotate(subtract(p->position,pivot),r)),translation);if(degrees!=0)p->rotation=rotate_euler_world(p->rotation,axis,degrees);}
|
||||
const auto chosen=[&](unsigned id){return std::find(ids.begin(),ids.end(),id)!=ids.end();};
|
||||
for(auto& l:links){if(machine_link_domain(*this,l)!=PortDomain::Gas||(!chosen(l.a.part)&&!chosen(l.b.part)))continue;auto a=find(l.a.part),b=find(l.b.part);if(a->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(l.a,l.b);}
|
||||
if(b->kind==MachinePartKind::Pipe&&chosen(a->id)&&!chosen(b->id))b->points[l.b.port?b->points.size()-1:0]=machine_local_point(*b,machine_port_position(*a,l.a.port));
|
||||
else a->points[l.a.port?a->points.size()-1:0]=machine_local_point(*a,machine_port_position(*b,l.b.port));
|
||||
}
|
||||
}
|
||||
void validate_machine(const MachineDocument& d){
|
||||
require(d.probes.size()<=machine_probe_limit,"A machine supports at most 8 measurement channels");std::set<std::uint32_t> probe_ids;
|
||||
for(const auto& probe:d.probes){require(probe.id&&probe_ids.insert(probe.id).second,"Invalid or duplicate probe ID");require(!probe.name.empty()&&probe.name.size()<=120,"Probe name must have 1..120 characters");require(int(probe.quantity)>=0&&int(probe.quantity)<=8&&range(probe.position,0,1)&&range(probe.reference_afr,1,50),"Invalid probe quantity or position");
|
||||
require(!probe.generated_output||probe.quantity==MachineQuantity::Torque||probe.quantity==MachineQuantity::Power,"Generated-output mode applies only to torque and power");
|
||||
if(probe.generated_output&&probe.part){const auto target=d.find(probe.part);require(target&&target->kind==MachinePartKind::Shaft,"Generated-output gauge needs a shaft target");}
|
||||
if(probe.quantity>=MachineQuantity::Rpm){if(probe.part){const auto part=d.find(probe.part);require(part&&machine_quantity_supported(*part,probe.quantity),"Rotational instrument needs a compatible target");}else require(machine_primary_shaft(d)||std::none_of(d.parts.begin(),d.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Shaft;}),"Choose a target for a rotational instrument");}else{const auto part=d.find(probe.part);require(part&&machine_quantity_supported(*part,probe.quantity),"Probe refers to a missing or incompatible part");}}
|
||||
require(d.parts.size()<=512&&d.links.size()<=2048,"Machine exceeds 512 parts / 2048 connections");
|
||||
require(!d.name.empty()&&d.name.size()<=120,"Machine name must have 1..120 characters");
|
||||
require(range(d.cells_per_meter,1,500)&&range(d.drive_rpm,0,50000)&&range(d.shaft_inertia,.001f,100)&&range(d.load_torque,0,10000),"Invalid simulation settings");
|
||||
require(range(d.friction_constant,0,1000)&&range(d.friction_viscous,0,100)&&range(d.friction_quadratic,0,10)&&range(d.starter_torque,0,1000),"Invalid shaft friction or starter torque");
|
||||
require(int(d.solver)>=0&&int(d.solver)<4&&int(d.scene)>=0&&int(d.scene)<5,"Invalid solver or scene");
|
||||
if(d.primary_shaft){const auto part=d.find(d.primary_shaft);require(part&&part->kind==MachinePartKind::Shaft,"Primary readout refers to a missing shaft");}
|
||||
std::set<std::uint32_t> ids;std::set<std::pair<unsigned,unsigned>> used;unsigned cells=0;
|
||||
for(auto& p:d.parts){
|
||||
const auto prefix=p.name+": ";require(p.id&&p.id<1000000000&&ids.insert(p.id).second,"Duplicate or invalid part ID");
|
||||
require(int(p.kind)>=0&&int(p.kind)<machine_part_kind_count,"Invalid part type");
|
||||
require(p.name.size()<=120&&finite(p.position)&&finite(p.rotation)&&finite(p.size),prefix+"invalid transform");
|
||||
require(std::max({std::abs(p.position.x),std::abs(p.position.y),std::abs(p.position.z)})<=100,prefix+"position must be within 100 m");
|
||||
require(range(p.size.x,.002f,10)&&range(p.size.y,.002f,10)&&range(p.size.z,.002f,10),prefix+"dimensions must be 2 mm..10 m");
|
||||
if(p.kind!=MachinePartKind::Pipe||p.point_diameters.empty())require(range(p.diameter,.001f,2)&&range(p.outlet_diameter,.001f,2),prefix+"diameters must be 1..2000 mm");
|
||||
require(range(p.pressure,1000,10000000)&&range(p.temperature,100,3000),prefix+"invalid initial pressure or temperature");
|
||||
require(range(p.opening,0,1)&&range(p.discharge,.01f,1)&&range(p.cracking_pressure,0,1000000)&&range(p.phase_degrees,0,720)&&range(p.turning_loss,0,10),prefix+"invalid valve settings");
|
||||
require(p.junction_method>=0&&p.junction_method<=2,prefix+"invalid junction method");
|
||||
if(p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::CheckValve)require(p.diameter<=std::min(p.size.y,p.size.z)+1e-6f,prefix+"throat diameter exceeds body dimensions; enlarge the body first");
|
||||
const bool custom_ports=(p.kind==MachinePartKind::Chamber||(p.kind==MachinePartKind::Junction&&!p.junction_method))&&!p.port_coordinates.empty();
|
||||
require(p.port_keys.empty()||(custom_ports&&p.port_keys.size()==p.port_coordinates.size()),prefix+"custom port keys must match port coordinates");
|
||||
std::set<std::string> port_keys;for(unsigned index=0;index<machine_port_count(p);++index){const auto descriptor=machine_port_descriptor(p,index);validate_port_descriptor(descriptor);require(port_keys.insert(descriptor.key).second,prefix+"duplicate port key");}
|
||||
require(p.port_coordinates.size()<=128,prefix+"too many ports");for(auto v:p.port_coordinates)require(finite(v)&&std::max({std::abs(v.x),std::abs(v.y),std::abs(v.z)})<=10,prefix+"invalid port coordinates");
|
||||
if(machine_rotational_part(p.kind)){const auto& shaft=p.shaft;const auto& drive=p.drive;const auto& brake=p.brake;
|
||||
require(range(shaft.inertia,.000001f,10000)&&range(shaft.initial_speed,-10000,10000)&&range(shaft.initial_angle,-1000000,1000000),prefix+"invalid rotational inertia or initial state");
|
||||
require(range(shaft.friction_constant,0,100000)&&range(shaft.friction_viscous,0,10000)&&range(shaft.friction_quadratic,0,1000)&&range(shaft.starter_torque,0,10000),prefix+"invalid shaft losses or starter calibration");
|
||||
require(range(drive.target_speed,-10000,10000)&&range(drive.torque,-1000000,1000000),prefix+"invalid motor command");
|
||||
require(range(brake.constant,0,1000000)&&range(brake.viscous,0,10000)&&range(brake.quadratic,0,1000),prefix+"invalid brake coefficients");
|
||||
}
|
||||
require(range(p.ignition_tdc_degrees,0,359.99999f),prefix+"invalid ignition TDC calibration");
|
||||
sensor_ecu_strategy_key(p.sensor_ecu.strategy);require(!p.sensor_ecu.enabled||p.kind==MachinePartKind::Ecu,prefix+"sensor ECU configuration requires an ECU part");
|
||||
if(p.injector){const auto& c=*p.injector;require(p.kind==MachinePartKind::Chamber,prefix+"injector attachment requires a chamber");require(c.ecu<1000000000&&c.ecu_label.size()<=120&&c.cylinders>=1&&c.cylinders<=64&&range(c.maximum_flow,.000000001f,1)&&range(c.initial_mass,0,1000)&&range(c.temperature,100,2000),prefix+"invalid injector supply or capacity");}
|
||||
if(p.kind==MachinePartKind::Ecu){const auto& e=p.ecu;
|
||||
if(p.sensor_ecu.enabled){SensorEcu controller(machine_sensor_ecu_config(p));}
|
||||
require(range(e.idle_rpm,0,30000)&&range(e.idle_base_opening,0,1)&&range(e.idle_max_opening,e.idle_base_opening,1)&&range(e.idle_kp,0,1)&&range(e.idle_ki,0,1),prefix+"invalid idle controller");
|
||||
require(range(e.rev_limit_rpm,100,50000)&&range(e.rev_resume_rpm,0,e.rev_limit_rpm-1)&&range(e.rev_limit_cut_time,0,10),prefix+"invalid rev limiter settings");
|
||||
require(range(e.stoichiometric_afr,5,50)&&range(e.power_afr,5,50)&&range(e.throttle_time_constant,.00001f,10)&&range(e.throttle_rate,.001f,100000)&&range(e.throttle_closing_time_constant,0,10)&&range(e.throttle_closing_rate,0,100000),prefix+"invalid fuel or throttle actuator settings");
|
||||
require(range(e.overrun_resume_rpm,0,e.overrun_cut_rpm)&&range(e.overrun_cut_rpm,0,50000)&&range(e.idle_spark_gain,0,1)&&range(e.idle_minimum_advance,-90,90),prefix+"invalid overrun / idle ignition settings");
|
||||
require(!e.ignition_map.empty()&&e.ignition_map.size()<=128,prefix+"ignition map needs 1..128 points");float previous=-1;for(auto point:e.ignition_map){require(range(point.rpm,0,100000)&&point.rpm>previous&&range(point.degrees_btdc,-60,90),prefix+"invalid ignition map");previous=point.rpm;}
|
||||
}
|
||||
if(p.kind==MachinePartKind::Carburetor){const auto& c=p.carburetor;
|
||||
require(range(c.pilot_jet_diameter,.000001f,.02f)&&range(c.main_jet_diameter,.000001f,.02f)&&range(c.needle_jet_diameter,.000001f,.02f)&&range(c.fuel_density,100,2000),prefix+"invalid fuel density or jets");
|
||||
require(range(c.bowl_capacity,.000001f,10)&&range(c.bowl_refill_rate,0,10)&&range(c.film_fraction,0,1)&&range(c.film_time_constant,.00001f,10)&&range(c.bowl_head_pressure,0,100000),prefix+"invalid float bowl or fuel film");
|
||||
require(range(c.pilot_discharge_coefficient,0,1)&&range(c.main_discharge_coefficient,0,1),prefix+"invalid jet discharge coefficient");
|
||||
auto copy=c;for(auto [key,value]:carb_values(copy))require(std::isfinite(*value)&&*value>=0&&*value<=100000,prefix+"invalid carburetor parameter "+key);
|
||||
require(c.overrun_exit_rpm<=c.overrun_rpm,prefix+"overrun resume must not exceed cut RPM");
|
||||
}
|
||||
if(p.kind==MachinePartKind::Pipe){
|
||||
require(p.points.size()>=2&&p.points.size()<=128,prefix+"pipe needs 2..128 path points");
|
||||
require(p.point_diameters.empty()||p.point_diameters.size()==p.points.size(),prefix+"pipe needs one diameter per point");
|
||||
for(float diameter:p.point_diameters)require(range(diameter,.001f,2),prefix+"point diameters must be 1..2000 mm");
|
||||
for(std::size_t i=0;i<p.points.size();++i){require(finite(p.points[i]),prefix+"invalid centerline");if(i)require(range(length(subtract(p.points[i],p.points[i-1])),.001f,20),prefix+"segments must be 1 mm..20 m");}
|
||||
const auto n=profile_section_cells(machine_pipe_length(p),1/d.cells_per_meter);require(n<=4096,prefix+"pipe mesh exceeds 4096 cells");cells+=unsigned(n);
|
||||
}
|
||||
if(cylinder_kind(p.kind)){
|
||||
auto& c=p.cylinder;require(range(c.bore,.005f,1)&&range(c.stroke,.002f,2)&&range(c.rod,c.stroke*.51f,10)&&range(c.compression,1.1f,100),prefix+"invalid bore, stroke, rod length, or compression ratio");
|
||||
require(range(c.intake_valve_diameter,.001f,c.bore*.8f)&&range(c.exhaust_valve_diameter,.001f,c.bore*.8f)&&c.intake_valve_count>=1&&c.intake_valve_count<=8&&c.exhaust_valve_count>=1&&c.exhaust_valve_count<=8,prefix+"invalid valve diameter/count");
|
||||
require(range(p.maximum_lift,.0001f,.05f)&&range(p.intake_open,0,719)&&range(p.intake_close,p.intake_open+1,720)&&range(p.exhaust_open,0,719)&&range(p.exhaust_close,p.exhaust_open+1,720),prefix+"invalid cam timing / lift");
|
||||
require(range(p.valve.mass_kg,.001f,1)&&range(p.valve.spring_rate_n_per_m,100,1000000)&&range(p.valve.damping_n_s_per_m,0,1000)&&range(p.valve.seat_force_n,0,10000),prefix+"invalid valve dynamics");
|
||||
require(range(c.afr,5,50)&&range(c.spark_advance,-20,60)&&range(c.heat_transfer,0,10000)&&range(c.wall_area,.00001f,10),prefix+"invalid cylinder calibration");
|
||||
require(range(c.burn_duration,1,720)&&range(c.burn_start,-60,120)&&range(c.wiebe_a,.01f,100)&&range(c.wiebe_m,0,10)&&range(c.fuel_lhv,1000,1e9f)&&range(c.energy_variation,0,1)&&range(c.misfire_probability,0,1)&&range(c.ambient_temperature,100,3000),prefix+"invalid combustion calibration");
|
||||
for(auto dynamics:{p.valve,p.exhaust_valve})require(range(dynamics.mass_kg,.001f,1)&&range(dynamics.spring_rate_n_per_m,100,1000000)&&range(dynamics.damping_n_s_per_m,0,1000)&&range(dynamics.seat_force_n,0,10000)&&range(dynamics.maximum_lift_m,.0001f,.1f)&&range(dynamics.seat_restitution,0,1)&&range(dynamics.cam_restitution,0,1)&&range(dynamics.stop_restitution,0,1),prefix+"invalid valve dynamics");
|
||||
for(auto profile:{&p.intake_profile,&p.exhaust_profile}){require(profile->points.size()<=2048,prefix+"cam has too many points");float previous=-1;for(auto point:profile->points){require(range(point.angle_degrees,0,720)&&point.angle_degrees>previous&&range(point.lift_metres,0,.05f),prefix+"invalid imported cam");previous=point.angle_degrees;}}
|
||||
}
|
||||
}
|
||||
require(cells<=65536,"Machine exceeds 65536 pipe cells");
|
||||
validate_machine_sensors(d);
|
||||
validate_machine_logic(d);
|
||||
require(d.next_id>0&&d.next_id<1000000000&&(ids.empty()||d.next_id>*ids.rbegin()),"Invalid next part ID");
|
||||
for(auto& l:d.links){
|
||||
auto a=d.find(l.a.part),b=d.find(l.b.part);require(a&&b&&a!=b,"Connection refers to a missing part or itself");
|
||||
require(l.a.port<machine_port_count(*a)&&l.b.port<machine_port_count(*b),"Connection uses an invalid port");
|
||||
const auto da=machine_port_descriptor(*a,l.a.port),db=machine_port_descriptor(*b,l.b.port);const auto compatible=port_compatibility(da,db);require(compatible.allowed,compatible.message);
|
||||
const auto claim=[&](const MachinePortDescriptor& descriptor,MachinePort port){if(descriptor.cardinality==PortCardinality::Multiple)return;require(used.emplace(port.part,port.port).second,"A port has multiple connections");};claim(da,l.a);claim(db,l.b);
|
||||
|
||||
if(l.kind==MachineConnectionKind::Mechanical){require(da.domain==PortDomain::Rotational&&a->kind==MachinePartKind::Shaft&&b->kind!=MachinePartKind::Shaft,"Invalid shaft attachment");continue;}
|
||||
if(l.kind==MachineConnectionKind::Control){require(da.domain==PortDomain::EcuAssignment&&da.direction==PortDirection::Output&&db.direction==PortDirection::Input,"Invalid ECU control wire");require(!a->sensor_ecu.enabled||b->kind==MachinePartKind::Cylinder,"Sensor-driven ECU assignments target cylinders; use explicit throttle commands");continue;}
|
||||
require(da.domain==PortDomain::Gas&&db.domain==PortDomain::Gas,"Gas methods require gas ports");
|
||||
require(a->kind==MachinePartKind::Pipe||b->kind==MachinePartKind::Pipe,"A connection must include a pipe");
|
||||
require(length(subtract(machine_port_position(*a,l.a.port),machine_port_position(*b,l.b.port)))<.0001f,"Connected ports must occupy the same position");
|
||||
require(int(l.kind)>=0&&int(l.kind)<4&&range(l.discharge,.01f,1)&&range(l.inertance_length,.0001f,10)&&range(l.loss,0,100),"Invalid connection properties");
|
||||
const bool both=a->kind==MachinePartKind::Pipe&&b->kind==MachinePartKind::Pipe;
|
||||
require(both?l.kind!=MachineConnectionKind::Inertance:int(l.kind)<=1,"Connection method is incompatible with these ports");
|
||||
const auto other=a->kind==MachinePartKind::Pipe?b:a;
|
||||
if(!both&&(other->kind==MachinePartKind::Reservoir||other->kind==MachinePartKind::Cap||(other->kind==MachinePartKind::Junction&&other->junction_method)))
|
||||
require(l.kind==MachineConnectionKind::Orifice,"Boundary and ideal-node ports use their component's boundary method");
|
||||
if(both)require(l.loss<=10,"Junction turning loss must be in 0..10");
|
||||
}
|
||||
for(const auto& shaft:d.parts)if(shaft.kind==MachinePartKind::Shaft){unsigned prescribed=0;for(const auto& part:d.parts)if(part.kind==MachinePartKind::DriveMotor&&part.drive.speed_control&&machine_shaft_for(d,part.id)==shaft.id){const bool signal_enable=std::any_of(d.logic.nodes.begin(),d.logic.nodes.end(),[&](const auto& node){return node.kind==LogicNodeKind::Command&&node.command==LogicCommand::MotorEnable&&node.part==part.id;});if(part.drive.enabled||signal_enable)++prescribed;}require(prescribed<=1,"A shaft supports only one potentially active ideal speed drive");}
|
||||
|
||||
}
|
||||
MachineConnectionPreview machine_connection_preview(const MachineDocument& d,MachinePort a,MachinePort b){
|
||||
MachineConnectionPreview result;
|
||||
try{
|
||||
auto proposed=d;proposed.connect(a,b);validate_machine(proposed);result.allowed=true;
|
||||
const auto pa=d.find(a.part),pb=d.find(b.part);
|
||||
if(machine_port_descriptor(*pa,a.port).domain==PortDomain::Rotational)result.message="Mechanical attachment: component shares the named shaft";
|
||||
else if(machine_control_port(*pa,a.port)||machine_control_port(*pb,b.port))result.message="ECU control wire: injection, ignition or throttle control";
|
||||
else{
|
||||
const auto pipe=pa->kind==MachinePartKind::Pipe?pa:pb;result.pipe=*proposed.find(pipe->id);
|
||||
result.message=proposed.parts.size()>d.parts.size()?"Connect and create a shared air junction":"Connect air ports; preview shows the new pipe route";
|
||||
}
|
||||
}catch(const std::exception& e){result.message=e.what();}
|
||||
return result;
|
||||
}
|
||||
bool machine_has_fuel_source(const MachineDocument& d,std::uint32_t cylinder){
|
||||
const auto target=d.find(cylinder);if(!target||target->kind!=MachinePartKind::Cylinder)return false;
|
||||
for(const auto& l:d.links)if(l.kind==MachineConnectionKind::Control&&l.b.part==cylinder){const auto ecu=d.find(l.a.part);if(ecu&&!ecu->sensor_ecu.enabled&&ecu->injection_enabled)return true;}
|
||||
std::vector<unsigned> visited{cylinder};
|
||||
for(std::size_t i=0;i<visited.size();++i)for(const auto& link:d.links)if(machine_link_domain(d,link)==PortDomain::Gas){
|
||||
auto here=link.a,other=link.b;if(here.part!=visited[i])std::swap(here,other);
|
||||
if(here.part!=visited[i]||(here.part==cylinder&&here.port!=0))continue;
|
||||
const auto next=d.find(other.part);if(!next)continue;
|
||||
if(next->kind==MachinePartKind::Carburetor)return true;
|
||||
if(next->injector){const auto ecu=d.find(next->injector->ecu);if(ecu&&ecu->kind==MachinePartKind::Ecu&&ecu->sensor_ecu.enabled&&ecu->sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition)return true;}
|
||||
if(next->kind==MachinePartKind::Cylinder)continue;
|
||||
if(std::find(visited.begin(),visited.end(),other.part)==visited.end())visited.push_back(other.part);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
std::vector<MachineCheck> machine_checks(const MachineDocument& d){
|
||||
std::vector<MachineCheck> result;
|
||||
for(const auto& sensor:d.sensors){const auto status=machine_sensor_binding(d,sensor);if(status!=MachineSensorStatus::Valid)result.push_back({sensor.target.part,true,std::string(machine_sensor_status_name(status))+": "+sensor.name,"Open Sense to bind the sensor to a compatible physical part. Unresolved outputs remain invalid.",sensor.id});}
|
||||
for(const auto& node:d.logic.nodes){const auto problem=logic_binding_problem(d,node);if(!problem.empty())result.push_back({node.part,true,node.name+": "+problem,"Open Logic and repair the explicit sensor/actuator reference.",0,node.id});
|
||||
for(const auto& port:logic_node_ports(node))if(port.required&&!std::any_of(d.logic.links.begin(),d.logic.links.end(),[&](const auto& link){return link.to.node==node.id&&link.to.port==port.key;}))result.push_back({node.part,true,node.name+" / "+port.name+": unconnected","Connect a compatible signal in Logic before Test.",0,node.id});
|
||||
}
|
||||
// Fuel-source reachability is a wiring/plumbing check, not a guarantee that
|
||||
// fuel can pass every valve or that the engine will sustain combustion.
|
||||
for(const auto& p:d.parts){
|
||||
if(((machine_rotational_part(p.kind)&&p.kind!=MachinePartKind::Shaft)||cylinder_kind(p.kind))&&!machine_shaft_for(d,p.id))result.push_back({p.id,true,"No shaft attached","Connect this part's gold mechanical port to a shaft."});
|
||||
if(p.kind==MachinePartKind::Cylinder&&!machine_has_fuel_source(d,p.id))result.push_back({p.id,true,"No fuel source", "Wire an injection ECU or pipe a carb to the intake."});
|
||||
if(p.kind==MachinePartKind::Ecu&&!std::any_of(d.links.begin(),d.links.end(),[&](const auto& l){return l.kind==MachineConnectionKind::Control&&l.a.part==p.id;}))result.push_back({p.id,true,"ECU has no assigned parts","Connect its blue output to blue part inputs."});
|
||||
if(p.kind==MachinePartKind::Ecu&&p.sensor_ecu.enabled&&!std::any_of(d.logic.nodes.begin(),d.logic.nodes.end(),[&](const auto& node){return node.kind==LogicNodeKind::Ecu&&node.part==p.id;}))result.push_back({p.id,true,"Sensor ECU has no signal connector","Use Show ECU connector in Logic, then wire its required measurements."});
|
||||
if(p.injector){const auto target=d.find(p.injector->ecu);if(!target||target->kind!=MachinePartKind::Ecu||!target->sensor_ecu.enabled||target->sensor_ecu.strategy==SensorEcuConfig::Strategy::Ignition)result.push_back({p.id,true,"Injector ECU binding is unresolved","Choose a sensor-driven EFI ECU in the chamber inspector."});}
|
||||
if(p.kind==MachinePartKind::Ecu){std::set<std::uint32_t> shafts;for(const auto& link:d.links)if(link.kind==MachineConnectionKind::Control&&link.a.part==p.id){const auto target=d.find(link.b.part);if(target&&target->kind==MachinePartKind::Cylinder){const auto shaft=machine_shaft_for(d,target->id);if(shaft)shafts.insert(shaft);}}if(shafts.size()>1)result.push_back({p.id,true,"ECU spans multiple shafts","Use a separate ECU for each independent engine shaft."});}
|
||||
if((p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor)&&!p.pedal_control&&p.opening<.001f&&!std::any_of(d.links.begin(),d.links.end(),[&](const auto& l){return l.kind==MachineConnectionKind::Control&&l.b.part==p.id;}))result.push_back({p.id,true,"Fixed throttle is closed","Increase opening or enable pedal control."});
|
||||
}
|
||||
for(const auto& p:d.parts)for(unsigned i=0;i<machine_port_count(p);++i)if(machine_port_descriptor(p,i).domain==PortDomain::Gas&&!occupied(d,{p.id,i})&&(p.kind==MachinePartKind::Pipe||cylinder_kind(p.kind)))
|
||||
result.push_back({p.id,false,machine_port_name(p,i)+" is sealed","Connect a pipe, or leave sealed intentionally."});
|
||||
for(const auto& shaft:d.parts)if(shaft.kind==MachinePartKind::Shaft&&std::abs(shaft.shaft.initial_speed)<.01f){
|
||||
const bool driven=std::any_of(d.parts.begin(),d.parts.end(),[&](const auto& p){return p.kind==MachinePartKind::DriveMotor&&p.drive.enabled&&machine_shaft_for(d,p.id)==shaft.id;});
|
||||
const bool engine=std::any_of(d.parts.begin(),d.parts.end(),[&](const auto& p){return p.kind==MachinePartKind::Cylinder&&machine_shaft_for(d,p.id)==shaft.id;});
|
||||
if(engine&&!driven)result.push_back({shaft.id,false,"Free shaft starts stopped",shaft.shaft.starter_torque>0?"Choose this primary shaft and hold V in Test to crank.":"Connect a drive to crank this engine."});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::vector<std::string> machine_diagnostics(const MachineDocument& d){
|
||||
std::vector<std::string> messages;
|
||||
for(auto& p:d.parts)for(unsigned i=0;i<machine_port_count(p);++i)if(machine_port_descriptor(p,i).domain==PortDomain::Gas&&!occupied(d,{p.id,i})&&(p.kind==MachinePartKind::Pipe||cylinder_kind(p.kind)))
|
||||
messages.push_back(p.name+" / "+machine_port_name(p,i)+": sealed (unconnected)");
|
||||
return messages;
|
||||
}
|
||||
std::filesystem::path machine_directory(){if(const char* path=std::getenv("FLUIDSIM_MACHINE_DIR");path&&*path)return path;return std::filesystem::current_path()/"machines";}
|
||||
std::vector<std::filesystem::path> machine_files(){
|
||||
std::vector<std::filesystem::path> result;auto directory=machine_directory();
|
||||
if(std::filesystem::is_directory(directory))for(auto& e:std::filesystem::directory_iterator(directory))if(e.is_regular_file()&&e.path().filename().string().ends_with(".machine.toml"))result.push_back(e.path());
|
||||
std::sort(result.begin(),result.end());return result;
|
||||
}
|
||||
std::filesystem::path machine_assembly_directory(){return machine_directory()/"assemblies";}
|
||||
std::vector<std::filesystem::path> machine_assembly_files(){
|
||||
std::vector<std::filesystem::path> result;const auto directory=machine_assembly_directory();
|
||||
if(std::filesystem::is_directory(directory))for(const auto& entry:std::filesystem::directory_iterator(directory))if(entry.is_regular_file()&&entry.path().filename().string().ends_with(".assembly.toml"))result.push_back(entry.path());
|
||||
std::sort(result.begin(),result.end());return result;
|
||||
}
|
||||
Vec3 machine_selection_center(const MachineDocument& document,const std::vector<std::uint32_t>& ids){
|
||||
Vec3 lo{1e6f,1e6f,1e6f},hi{-1e6f,-1e6f,-1e6f};
|
||||
const auto include=[&](Vec3 point){lo={std::min(lo.x,point.x),std::min(lo.y,point.y),std::min(lo.z,point.z)};hi={std::max(hi.x,point.x),std::max(hi.y,point.y),std::max(hi.z,point.z)};};
|
||||
for(const auto id:ids){const auto* part=document.find(id);require(part,"Selection contains a part that no longer exists");
|
||||
if(part->kind==MachinePartKind::Pipe){const auto points=machine_pipe_path(*part);const auto diameters=machine_pipe_diameters(*part);for(std::size_t i=0;i<points.size();++i){const float r=diameters[i]*.5f;include(add(points[i],{r,r,r}));include(subtract(points[i],{r,r,r}));}}
|
||||
else{const bool symbol=part->kind==MachinePartKind::Reservoir||part->kind==MachinePartKind::Cap||(part->kind==MachinePartKind::Junction&&part->junction_method);const auto size=symbol?Vec3{.04f,.04f,.04f}:machine_part_size(*part);for(int corner=0;corner<8;++corner)include(machine_world_point(*part,{(corner&1?1:-1)*size.x*.5f,(corner&2?1:-1)*size.y*.5f,(corner&4?1:-1)*size.z*.5f}));}
|
||||
}
|
||||
return ids.empty()?Vec3{}:multiply(add(lo,hi),.5f);
|
||||
}
|
||||
MachineDocument machine_extract_assembly(const MachineDocument& source,const std::vector<std::uint32_t>& ids,std::string name){
|
||||
std::set<std::uint32_t> chosen(ids.begin(),ids.end());require(!chosen.empty(),"Select at least one part to save as an assembly");
|
||||
MachineDocument assembly;assembly.name=name.empty()?"Reusable assembly":std::move(name);assembly.cells_per_meter=source.cells_per_meter;
|
||||
const auto center=machine_selection_center(source,ids);
|
||||
for(const auto& part:source.parts)if(chosen.contains(part.id)){assembly.parts.push_back(part);assembly.next_id=std::max(assembly.next_id,part.id+1);}
|
||||
for(auto& part:assembly.parts){part.position=subtract(part.position,center);if(part.injector&&!chosen.contains(part.injector->ecu)){if(const auto ecu=source.find(part.injector->ecu))part.injector->ecu_label=ecu->name;part.injector->ecu=0;}}
|
||||
for(const auto& link:source.links)if(chosen.contains(link.a.part)&&chosen.contains(link.b.part))assembly.links.push_back(link);
|
||||
for(const auto& probe:source.probes)if(probe.part&&chosen.contains(probe.part)){
|
||||
auto copy=probe;copy.id=std::uint32_t(assembly.probes.size()+1);assembly.probes.push_back(std::move(copy));
|
||||
}
|
||||
for(auto sensor:source.sensors)if(chosen.contains(sensor.target.part)||chosen.contains(sensor.reference.part)){
|
||||
for(auto* tap:{&sensor.target,&sensor.reference})if(!chosen.contains(tap->part)){if(const auto host=source.find(tap->part))tap->label=host->name;tap->part=0;}
|
||||
assembly.next_sensor_id=std::max(assembly.next_sensor_id,sensor.id+1);assembly.sensors.push_back(std::move(sensor));
|
||||
}
|
||||
if(chosen.contains(source.primary_shaft))assembly.primary_shaft=source.primary_shaft;
|
||||
extract_machine_logic(source,chosen,assembly);
|
||||
validate_machine(assembly);return assembly;
|
||||
}
|
||||
std::vector<std::uint32_t> machine_insert_assembly(MachineDocument& destination,const MachineDocument& assembly,Vec3 center){
|
||||
validate_machine(assembly);require(!assembly.parts.empty(),"Assembly contains no parts");require(finite(center),"Assembly placement must be finite");
|
||||
require(destination.parts.size()+assembly.parts.size()<=512,"Assembly would exceed the 512-part machine limit");
|
||||
require(destination.links.size()+assembly.links.size()<=2048,"Assembly would exceed the 2048-connection limit");
|
||||
require(destination.probes.size()+assembly.probes.size()<=machine_probe_limit,"Remove a probe before inserting this assembly (8-channel limit)");
|
||||
auto proposed=destination;std::unordered_map<std::uint32_t,std::uint32_t> mapped;std::vector<std::uint32_t> inserted;inserted.reserve(assembly.parts.size());
|
||||
for(const auto& part:assembly.parts){auto copy=part;copy.id=proposed.next_id++;copy.position=add(copy.position,center);mapped.emplace(part.id,copy.id);inserted.push_back(copy.id);proposed.parts.push_back(std::move(copy));}
|
||||
for(const auto id:inserted)if(auto& injector=proposed.find(id)->injector;injector)injector->ecu=mapped.contains(injector->ecu)?mapped.at(injector->ecu):0;
|
||||
for(auto link:assembly.links){require(mapped.contains(link.a.part)&&mapped.contains(link.b.part),"Assembly has a connection to a missing part");link.a.part=mapped.at(link.a.part);link.b.part=mapped.at(link.b.part);proposed.links.push_back(link);}
|
||||
std::uint32_t next_probe=1;for(const auto& probe:proposed.probes)next_probe=std::max(next_probe,probe.id+1);
|
||||
for(auto probe:assembly.probes){require(mapped.contains(probe.part),"Assembly probe refers to a missing part");probe.id=next_probe++;probe.part=mapped.at(probe.part);proposed.probes.push_back(std::move(probe));}
|
||||
std::unordered_map<std::uint32_t,std::uint32_t> sensor_ids;
|
||||
for(auto sensor:assembly.sensors){const auto original=sensor.id;sensor.id=proposed.next_sensor_id++;sensor_ids.emplace(original,sensor.id);for(auto* tap:{&sensor.target,&sensor.reference})tap->part=mapped.contains(tap->part)?mapped.at(tap->part):0;proposed.sensors.push_back(std::move(sensor));}
|
||||
insert_machine_logic(proposed,assembly,mapped,sensor_ids);
|
||||
if(!proposed.primary_shaft&&mapped.contains(assembly.primary_shaft))proposed.primary_shaft=mapped.at(assembly.primary_shaft);
|
||||
validate_machine(proposed);destination=std::move(proposed);return inserted;
|
||||
}
|
||||
|
||||
MachineDocument load_machine(const std::filesystem::path& file){
|
||||
try{
|
||||
const auto table=toml::parse_file(file.string());const auto format=table["format"].value_or(0);require(format>=1&&format<=10,"Unsupported machine file version");
|
||||
MachineDocument d;d.name=table["name"].value_or(std::string("Untitled machine"));
|
||||
d.cells_per_meter=read_number(table,"cells_per_meter",40);d.drive_rpm=read_number(table,"drive_rpm",format==1?1500.f:0.f);d.shaft_inertia=read_number(table,"shaft_inertia",.15f);d.load_torque=read_number(table,"load_torque",0);d.driven=table["driven"].value_or(format==1);
|
||||
d.friction_constant=read_number(table,"friction_constant",d.friction_constant);d.friction_viscous=read_number(table,"friction_viscous",d.friction_viscous);d.friction_quadratic=read_number(table,"friction_quadratic",0);d.starter_torque=read_number(table,"starter_torque",15);
|
||||
if(format>=6){const auto id=table["primary_shaft"].value_or(int64_t(0));require(id>=0&&id<1000000000,"Invalid primary shaft ID");d.primary_shaft=std::uint32_t(id);}
|
||||
d.solver=parse_pipe_solver(table["solver"].value_or(std::string("rusanov")));const auto scene=table["scene"].value_or(2);require(scene>=0&&scene<5,"Invalid scene");d.scene=OutdoorScene(scene);
|
||||
auto parts=table["parts"].as_array();require(parts&&parts->size()<=512,"Missing parts array or too many parts");
|
||||
for(auto& item:*parts){
|
||||
auto t=item.as_table();require(t!=nullptr,"Invalid part table");MachinePart p;p.id=read_id((*t)["id"]);p.kind=read_kind((*t)["kind"].value_or(std::string{}));p.name=(*t)["name"].value_or(std::string(machine_part_name(p.kind)));
|
||||
p.position=read_xyz((*t)["position_m"],p.position);p.rotation=read_xyz((*t)["rotation_deg"]);p.size=read_xyz((*t)["size_m"],p.size);
|
||||
if(auto points=(*t)["points_m"].as_array()){require(points->size()<=128,"Too many pipe points");p.points.clear();for(auto& point:*points){auto a=point.as_array();require(a&&a->size()==3,"Invalid pipe point");Vec3 v;float* values[]{&v.x,&v.y,&v.z};for(int i=0;i<3;++i){auto n=(*a)[i].value<double>();require(bool(n),"Invalid pipe coordinate");*values[i]=float(*n);}p.points.push_back(v);}}
|
||||
const auto get=[&](const char* key,float& value){value=read_number(*t,key,value);};
|
||||
get("diameter_m",p.diameter);get("outlet_diameter_m",p.outlet_diameter);get("pressure_pa",p.pressure);get("temperature_k",p.temperature);get("opening",p.opening);get("discharge",p.discharge);get("cracking_pressure_pa",p.cracking_pressure);get("phase_deg",p.phase_degrees);get("maximum_lift_m",p.maximum_lift);get("intake_open_deg",p.intake_open);get("intake_close_deg",p.intake_close);get("exhaust_open_deg",p.exhaust_open);get("exhaust_close_deg",p.exhaust_close);get("turning_loss",p.turning_loss);
|
||||
if(p.kind==MachinePartKind::Pipe){const auto values=(*t)["point_diameters_m"].as_array();require(format<3||values,"Pipe needs point_diameters_m in version 3");
|
||||
if((*t).contains("point_diameters_m")){require(values&&values->size()==p.points.size(),"Pipe needs one diameter per point");for(const auto& value:*values){const auto number=value.value<double>();require(bool(number),"Invalid pipe point diameter");p.point_diameters.push_back(float(*number));}}
|
||||
}
|
||||
p.pedal_control=(*t)["pedal_control"].value_or(true);p.junction_method=(*t)["junction_method"].value_or(0);
|
||||
auto& c=p.cylinder;get("bore_m",c.bore);get("stroke_m",c.stroke);get("rod_m",c.rod);get("compression",c.compression);get("intake_valve_m",c.intake_valve_diameter);get("exhaust_valve_m",c.exhaust_valve_diameter);get("afr",c.afr);get("spark_advance_deg",c.spark_advance);get("heat_transfer",c.heat_transfer);get("wall_area_m2",c.wall_area);
|
||||
c.intake_valve_count=(*t)["intake_valve_count"].value_or(1);c.exhaust_valve_count=(*t)["exhaust_valve_count"].value_or(1);
|
||||
get("valve_mass_kg",p.valve.mass_kg);get("valve_spring_n_m",p.valve.spring_rate_n_per_m);get("valve_damping",p.valve.damping_n_s_per_m);get("valve_preload_n",p.valve.seat_force_n);
|
||||
get("burn_duration_deg",c.burn_duration);get("burn_start_deg",c.burn_start);get("wiebe_a",c.wiebe_a);get("wiebe_m",c.wiebe_m);get("fuel_lhv",c.fuel_lhv);get("energy_variation",c.energy_variation);get("misfire_probability",c.misfire_probability);get("ambient_temperature",c.ambient_temperature);
|
||||
if(auto config=(*t)["intake_dynamics"].as_table())read_numbers(*config,valve_values(p.valve));
|
||||
p.exhaust_valve=p.valve;
|
||||
if(auto config=(*t)["exhaust_dynamics"].as_table())read_numbers(*config,valve_values(p.exhaust_valve));
|
||||
if(auto points=(*t)["intake_profile"].as_array())p.intake_profile=read_cam(*points);
|
||||
if(auto points=(*t)["exhaust_profile"].as_array())p.exhaust_profile=read_cam(*points);
|
||||
if(auto coordinates=(*t)["port_coordinates"].as_array()){require(coordinates->size()<=128,"Too many ports");for(auto& item:*coordinates){auto a=item.as_array();require(a&&a->size()==3,"Port needs three coordinates");Vec3 v;float* values[]{&v.x,&v.y,&v.z};for(int i=0;i<3;++i){auto n=(*a)[i].value<double>();require(bool(n),"Invalid port coordinate");*values[i]=float(*n);}p.port_coordinates.push_back(v);}}
|
||||
if((*t).contains("port_keys")){const auto values=(*t)["port_keys"].as_array();require(values&&values->size()<=128,"Invalid custom port keys");for(const auto& value:*values){const auto key=value.value<std::string>();require(bool(key),"Port key must be a string");p.port_keys.push_back(*key);}}
|
||||
const bool custom_ports=(p.kind==MachinePartKind::Chamber||(p.kind==MachinePartKind::Junction&&!p.junction_method))&&!p.port_coordinates.empty();
|
||||
if(custom_ports&&p.port_keys.empty()){require(format<5,"Version 5 custom ports require stable keys");for(unsigned i=0;i<machine_port_count(p);++i)p.port_keys.push_back("gas_"+std::to_string(i));}
|
||||
p.injection_enabled=(*t)["injection_enabled"].value_or(true);
|
||||
if(auto config=(*t)["ecu"].as_table()){auto& e=p.ecu;read_numbers(*config,ecu_values(e));e.idle_control=(*config)["fixed_idle"].value_or(false)?EcuConfig::IdleControl::FixedOpening:EcuConfig::IdleControl::Feedback;
|
||||
e.rev_limit_enabled=(*config)["rev_limit_enabled"].value_or(true);e.rev_limit_fuel_cut_enabled=(*config)["rev_limit_fuel_cut_enabled"].value_or(true);e.rev_limit_ignition_cut_enabled=(*config)["rev_limit_ignition_cut_enabled"].value_or(true);e.overrun_fuel_cut_enabled=(*config)["overrun_fuel_cut_enabled"].value_or(true);e.overrun_ignition_cut_enabled=(*config)["overrun_ignition_cut_enabled"].value_or(false);
|
||||
if(auto map=(*config)["ignition_map"].as_array()){require(map->size()<=128,"Too many ignition points");e.ignition_map.clear();for(auto& item:*map){auto pair=item.as_array();require(pair&&pair->size()==2,"Ignition point needs RPM and advance");auto rpm=(*pair)[0].value<double>(),advance=(*pair)[1].value<double>();require(rpm&&advance,"Invalid ignition point");e.ignition_map.push_back({float(*rpm),float(*advance)});}}
|
||||
}
|
||||
get("ignition_tdc_deg",p.ignition_tdc_degrees);
|
||||
require(!t->contains("injector")||(*t)["injector"].is_table(),"Injector must be a table");
|
||||
if(auto config=(*t)["injector"].as_table()){
|
||||
require(!config->contains("finite_supply")||bool((*config)["finite_supply"].value<bool>()),"Injector finite_supply must be Boolean");
|
||||
MachineInjectorConfig c;const auto ecu=(*config)["ecu"].value_or(int64_t(0)),count=(*config)["cylinders"].value_or(int64_t(1));require(ecu>=0&&ecu<1000000000&&count>=1&&count<=64,"Invalid injector ECU or cylinder count");c.ecu=std::uint32_t(ecu);c.cylinders=unsigned(count);c.ecu_label=(*config)["ecu_label"].value_or(std::string{});c.finite_supply=(*config)["finite_supply"].value_or(true);read_numbers(*config,{{"maximum_kg_s",&c.maximum_flow},{"initial_kg",&c.initial_mass},{"temperature_k",&c.temperature}});p.injector=c;
|
||||
}
|
||||
require(!t->contains("sensor_ecu")||(*t)["sensor_ecu"].is_table(),"Sensor ECU must be a table");
|
||||
if(auto config=(*t)["sensor_ecu"].as_table()){
|
||||
require(!config->contains("enabled")||bool((*config)["enabled"].value<bool>()),"Sensor ECU enabled must be Boolean");require(!config->contains("strategy")||bool((*config)["strategy"].value<std::string>()),"Sensor ECU strategy must be text");
|
||||
auto& c=p.sensor_ecu;c.enabled=(*config)["enabled"].value_or(false);c.strategy=sensor_ecu_strategy_from_key((*config)["strategy"].value_or(std::string("ignition")));
|
||||
const auto array=[&](const char* key,std::vector<float>& target){if(!config->contains(key))return;const auto source=(*config)[key].as_array();require(source&&source->size()<=65536,"Invalid ECU fuel table array");target.clear();for(const auto& item:*source){const auto value=item.value<double>();require(bool(value),"Fuel table entries must be numeric");target.push_back(float(*value));}};
|
||||
array("fuel_rpm",c.fuel_rpm);array("fuel_tps",c.fuel_tps);array("fuel_kg_cycle",c.fuel_per_cycle);
|
||||
array("ve_rpm",c.ve_rpm);array("ve_absolute_pa",c.ve_pressure);array("volumetric_efficiency",c.volumetric_efficiency);read_numbers(*config,{{"displacement_m3",&c.displacement},{"air_r_j_kg_k",&c.air_gas_constant},{"target_afr",&c.target_afr}});
|
||||
}
|
||||
if(auto config=(*t)["carburetor"].as_table())read_numbers(*config,carb_values(p.carburetor));
|
||||
if(auto config=(*t)["shaft"].as_table()){auto& c=p.shaft;read_numbers(*config,{{"inertia_kg_m2",&c.inertia},{"initial_speed_rad_s",&c.initial_speed},{"initial_angle_rad",&c.initial_angle},{"friction_nm",&c.friction_constant},{"viscous_nm_s",&c.friction_viscous},{"quadratic_nm_s2",&c.friction_quadratic},{"starter_nm",&c.starter_torque}});}
|
||||
if(auto config=(*t)["drive"].as_table()){auto& c=p.drive;read_numbers(*config,{{"target_speed_rad_s",&c.target_speed},{"torque_nm",&c.torque}});c.speed_control=(*config)["speed_control"].value_or(true);c.enabled=(*config)["enabled"].value_or(true);}
|
||||
if(auto config=(*t)["brake"].as_table()){auto& c=p.brake;read_numbers(*config,{{"constant_nm",&c.constant},{"viscous_nm_s",&c.viscous},{"quadratic_nm_s2",&c.quadratic}});c.enabled=(*config)["enabled"].value_or(true);}
|
||||
|
||||
d.next_id=std::max(d.next_id,p.id+1);d.parts.push_back(std::move(p));
|
||||
}
|
||||
if(auto links=table["connections"].as_array()){
|
||||
require(links->size()<=2048,"Too many connections");
|
||||
for(const auto& item:*links){
|
||||
const auto t=item.as_table();require(t,"Invalid connection table");MachineLink link;link.a.part=read_id((*t)["a_part"]);link.b.part=read_id((*t)["b_part"]);
|
||||
if(format>=5){
|
||||
const auto a=d.find(link.a.part),b=d.find(link.b.part);require(a&&b,"Connection refers to a missing part");
|
||||
const auto ak=(*t)["a_port"].value<std::string>(),bk=(*t)["b_port"].value<std::string>(),method=(*t)["method"].value<std::string>();require(ak&&bk&&method,"Version 5 connections require named ports and method");
|
||||
link.a.port=machine_port_index(*a,*ak);link.b.port=machine_port_index(*b,*bk);bool found=false;
|
||||
for(int i=0;i<machine_connection_kind_count;++i)if(*method==machine_connection_key(MachineConnectionKind(i))){link.kind=MachineConnectionKind(i);found=true;break;}
|
||||
require(found,"Unknown connection method: "+*method);
|
||||
}else{
|
||||
const auto ap=(*t)["a_port"].value_or(-1),bp=(*t)["b_port"].value_or(-1),method=(*t)["method"].value_or(0);require(ap>=0&&ap<128&&bp>=0&&bp<128&&method>=0&&method<=4,"Invalid connection port / method");
|
||||
const auto migrate_port=[&](unsigned id,unsigned index){const auto part=d.find(id);require(part,"Connection refers to a missing part");if(part->kind==MachinePartKind::Junction&&part->junction_method){const auto legacy_count=part->port_coordinates.empty()?6:part->port_coordinates.size();require(index<legacy_count,"Invalid legacy junction port");return 0u;}require(index<machine_port_count(*part),"Invalid legacy port index");return index;};
|
||||
link.a.port=migrate_port(link.a.part,unsigned(ap));link.b.port=migrate_port(link.b.part,unsigned(bp));link.kind=MachineConnectionKind(method);
|
||||
}
|
||||
link.discharge=read_number(*t,"discharge",1);link.inertance_length=read_number(*t,"inertance_length_m",.01f);link.loss=read_number(*t,"loss",0);d.links.push_back(link);
|
||||
}
|
||||
}
|
||||
if(auto probes=table["probes"].as_array()){require(probes->size()<=machine_probe_limit,"Too many probes");for(const auto& item:*probes){const auto p=item.as_table();require(p,"Invalid probe table");MachineProbe probe;probe.id=read_id((*p)["id"]);const auto part=(*p)["part"].value_or(int64_t(-1));require(part>=0&&part<1000000000,"Invalid probe part");probe.part=std::uint32_t(part);const auto quantity=(*p)["quantity"].value_or(-1);require(quantity>=0&&quantity<=8,"Invalid probe quantity");probe.quantity=MachineQuantity(quantity);probe.name=(*p)["name"].value_or(std::string(machine_quantity_name(probe.quantity)));probe.position=read_number(*p,"position",.5f);probe.reference_afr=read_number(*p,"reference_afr",14.7f);probe.generated_output=(*p)["generated_output"].value_or(format<=5&&(probe.quantity==MachineQuantity::Torque||probe.quantity==MachineQuantity::Power));d.probes.push_back(std::move(probe));}}
|
||||
if(table.contains("sensors")){
|
||||
const auto sensors=table["sensors"].as_array();require(sensors&&sensors->size()<=1024,"Invalid sensor array");
|
||||
for(const auto& item:*sensors){const auto s=item.as_table();require(s,"Invalid sensor table");MachineSensor sensor;sensor.id=read_id((*s)["id"]);
|
||||
const auto key=(*s)["kind"].value_or(std::string{});bool found=false;for(int i=0;i<machine_sensor_kind_count;++i)if(key==machine_sensor_key(MachineSensorKind(i))){sensor.kind=MachineSensorKind(i);found=true;break;}require(found,"Unknown sensor kind: "+key);
|
||||
sensor.name=(*s)["name"].value_or(std::string(machine_sensor_name(sensor.kind)));sensor.angle_offset=read_number(*s,"angle_offset_rad",0);
|
||||
const auto tap=[&](const char* key,MachineSensorTap& target){const auto t=(*s)[key].as_table();require(t,std::string("Sensor needs ")+key+" binding");const auto id=(*t)["part"].value<int64_t>();require(id&&*id>=0&&*id<1000000000,"Invalid sensor host ID");target.part=std::uint32_t(*id);target.position=read_number(*t,"position",.5f);target.label=(*t)["label"].value_or(std::string{});if(!d.find(target.part))target.part=0;};
|
||||
tap("target",sensor.target);tap("reference",sensor.reference);d.next_sensor_id=std::max(d.next_sensor_id,sensor.id+1);d.sensors.push_back(std::move(sensor));
|
||||
}
|
||||
}
|
||||
for(auto [key,value]:{std::pair{"next_id",&d.next_id},std::pair{"next_sensor_id",&d.next_sensor_id}})if(table.contains(key)){const auto id=table[key].value<int64_t>();require(id&&*id>=*value&&*id<1000000000,std::string("Invalid ")+key);*value=std::uint32_t(*id);}
|
||||
d.logic=read_logic_graph(table);for(auto& n:d.logic.nodes){if(n.kind==LogicNodeKind::Sensor&&!d.find_sensor(n.sensor))n.sensor=0;if((n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu)&&!d.find(n.part))n.part=0;}for(auto& p:d.parts)if(p.injector&&!d.find(p.injector->ecu))p.injector->ecu=0;
|
||||
validate_machine(d);if(format<=5){upgrade_machine_mechanics(d);validate_machine(d);}for(auto& p:d.parts)if(p.kind==MachinePartKind::Pipe&&p.point_diameters.empty())p.point_diameters=machine_pipe_diameters(p);return d;
|
||||
}catch(const std::exception& error){throw std::runtime_error(file.filename().string()+": "+error.what());}
|
||||
}
|
||||
void save_machine(const MachineDocument& d,const std::filesystem::path& file){
|
||||
validate_machine(d);toml::table t{{"format",10},{"name",d.name},{"cells_per_meter",double(d.cells_per_meter)},{"solver",pipe_solver_name(d.solver)},{"scene",int(d.scene)},{"primary_shaft",int64_t(d.primary_shaft)},{"next_id",int64_t(d.next_id)},{"next_sensor_id",int64_t(d.next_sensor_id)}};
|
||||
t.insert("logic",write_logic_graph(d.logic));
|
||||
toml::array parts,links;
|
||||
for(auto& p:d.parts){
|
||||
toml::table pt{{"id",int64_t(p.id)},{"kind",machine_part_key(p.kind)},{"name",p.name},{"position_m",xyz(p.position)},{"rotation_deg",xyz(p.rotation)},{"size_m",xyz(p.size)},{"pedal_control",p.pedal_control},{"junction_method",p.junction_method}};
|
||||
const auto put=[&](const char* key,float v){pt.insert(key,double(v));};
|
||||
if(p.kind==MachinePartKind::Pipe){toml::array diameters;for(float diameter:machine_pipe_diameters(p))diameters.push_back(double(diameter));pt.insert("point_diameters_m",std::move(diameters));}
|
||||
else{put("diameter_m",p.diameter);put("outlet_diameter_m",p.outlet_diameter);}
|
||||
put("pressure_pa",p.pressure);put("temperature_k",p.temperature);put("opening",p.opening);put("discharge",p.discharge);put("cracking_pressure_pa",p.cracking_pressure);put("phase_deg",p.phase_degrees);put("maximum_lift_m",p.maximum_lift);put("intake_open_deg",p.intake_open);put("intake_close_deg",p.intake_close);put("exhaust_open_deg",p.exhaust_open);put("exhaust_close_deg",p.exhaust_close);put("turning_loss",p.turning_loss);
|
||||
auto& c=p.cylinder;put("bore_m",c.bore);put("stroke_m",c.stroke);put("rod_m",c.rod);put("compression",c.compression);put("intake_valve_m",c.intake_valve_diameter);put("exhaust_valve_m",c.exhaust_valve_diameter);put("afr",c.afr);put("spark_advance_deg",c.spark_advance);put("heat_transfer",c.heat_transfer);put("wall_area_m2",c.wall_area);pt.insert("intake_valve_count",c.intake_valve_count);pt.insert("exhaust_valve_count",c.exhaust_valve_count);
|
||||
put("valve_mass_kg",p.valve.mass_kg);put("valve_spring_n_m",p.valve.spring_rate_n_per_m);put("valve_damping",p.valve.damping_n_s_per_m);put("valve_preload_n",p.valve.seat_force_n);
|
||||
put("burn_duration_deg",c.burn_duration);put("burn_start_deg",c.burn_start);put("wiebe_a",c.wiebe_a);put("wiebe_m",c.wiebe_m);put("fuel_lhv",c.fuel_lhv);put("energy_variation",c.energy_variation);put("misfire_probability",c.misfire_probability);put("ambient_temperature",c.ambient_temperature);
|
||||
auto intake=p.valve,exhaust=p.exhaust_valve;pt.insert("intake_dynamics",write_numbers(valve_values(intake)));pt.insert("exhaust_dynamics",write_numbers(valve_values(exhaust)));pt.insert("intake_profile",write_cam(p.intake_profile));pt.insert("exhaust_profile",write_cam(p.exhaust_profile));
|
||||
toml::array coordinates;for(auto v:p.port_coordinates)coordinates.push_back(xyz(v));pt.insert("port_coordinates",std::move(coordinates));pt.insert("injection_enabled",p.injection_enabled);
|
||||
if((p.kind==MachinePartKind::Chamber||(p.kind==MachinePartKind::Junction&&!p.junction_method))&&!p.port_coordinates.empty()){toml::array stable_keys;for(unsigned i=0;i<machine_port_count(p);++i)stable_keys.push_back(machine_port_descriptor(p,i).key);pt.insert("port_keys",std::move(stable_keys));}
|
||||
|
||||
if(p.kind==MachinePartKind::Cylinder)put("ignition_tdc_deg",p.ignition_tdc_degrees);
|
||||
if(p.injector){const auto& c=*p.injector;pt.insert("injector",toml::table{{"ecu",int64_t(c.ecu)},{"ecu_label",c.ecu_label},{"cylinders",int64_t(c.cylinders)},{"finite_supply",c.finite_supply},{"maximum_kg_s",double(c.maximum_flow)},{"initial_kg",double(c.initial_mass)},{"temperature_k",double(c.temperature)}});}
|
||||
if(p.kind==MachinePartKind::Ecu){auto e=p.ecu;auto config=write_numbers(ecu_values(e));config.insert("fixed_idle",e.idle_control==EcuConfig::IdleControl::FixedOpening);config.insert("rev_limit_enabled",e.rev_limit_enabled);config.insert("rev_limit_fuel_cut_enabled",e.rev_limit_fuel_cut_enabled);config.insert("rev_limit_ignition_cut_enabled",e.rev_limit_ignition_cut_enabled);config.insert("overrun_fuel_cut_enabled",e.overrun_fuel_cut_enabled);config.insert("overrun_ignition_cut_enabled",e.overrun_ignition_cut_enabled);toml::array map;for(auto point:e.ignition_map)map.push_back(toml::array{double(point.rpm),double(point.degrees_btdc)});config.insert("ignition_map",std::move(map));pt.insert("ecu",std::move(config));
|
||||
const auto& c=p.sensor_ecu;toml::table sensor{{"enabled",c.enabled},{"strategy",sensor_ecu_strategy_key(c.strategy)},{"displacement_m3",double(c.displacement)},{"air_r_j_kg_k",double(c.air_gas_constant)},{"target_afr",double(c.target_afr)}};const auto array=[&](const char* key,const std::vector<float>& values){toml::array data;for(const auto value:values)data.push_back(double(value));sensor.insert(key,std::move(data));};array("fuel_rpm",c.fuel_rpm);array("fuel_tps",c.fuel_tps);array("fuel_kg_cycle",c.fuel_per_cycle);array("ve_rpm",c.ve_rpm);array("ve_absolute_pa",c.ve_pressure);array("volumetric_efficiency",c.volumetric_efficiency);pt.insert("sensor_ecu",std::move(sensor));
|
||||
}
|
||||
if(p.kind==MachinePartKind::Carburetor){auto config=p.carburetor;pt.insert("carburetor",write_numbers(carb_values(config)));}
|
||||
if(p.kind==MachinePartKind::Shaft||p.kind==MachinePartKind::Flywheel){const auto& c=p.shaft;pt.insert("shaft",toml::table{{"inertia_kg_m2",double(c.inertia)},{"initial_speed_rad_s",double(c.initial_speed)},{"initial_angle_rad",double(c.initial_angle)},{"friction_nm",double(c.friction_constant)},{"viscous_nm_s",double(c.friction_viscous)},{"quadratic_nm_s2",double(c.friction_quadratic)},{"starter_nm",double(c.starter_torque)}});}
|
||||
if(p.kind==MachinePartKind::DriveMotor){const auto& c=p.drive;pt.insert("drive",toml::table{{"target_speed_rad_s",double(c.target_speed)},{"torque_nm",double(c.torque)},{"speed_control",c.speed_control},{"enabled",c.enabled}});}
|
||||
if(p.kind==MachinePartKind::Brake){const auto& c=p.brake;pt.insert("brake",toml::table{{"constant_nm",double(c.constant)},{"viscous_nm_s",double(c.viscous)},{"quadratic_nm_s2",double(c.quadratic)},{"enabled",c.enabled}});}
|
||||
|
||||
toml::array points;for(auto v:p.points)points.push_back(xyz(v));pt.insert("points_m",std::move(points));parts.push_back(std::move(pt));
|
||||
}
|
||||
for(auto& l:d.links)links.push_back(toml::table{{"a_part",int64_t(l.a.part)},{"a_port",machine_port_descriptor(*d.find(l.a.part),l.a.port).key},{"b_part",int64_t(l.b.part)},{"b_port",machine_port_descriptor(*d.find(l.b.part),l.b.port).key},{"method",machine_connection_key(l.kind)},{"discharge",double(l.discharge)},{"inertance_length_m",double(l.inertance_length)},{"loss",double(l.loss)}});
|
||||
toml::array probes;for(const auto& p:d.probes)probes.push_back(toml::table{{"id",int64_t(p.id)},{"part",int64_t(p.part)},{"name",p.name},{"quantity",int(p.quantity)},{"position",double(p.position)},{"reference_afr",double(p.reference_afr)},{"generated_output",p.generated_output}});t.insert("probes",std::move(probes));
|
||||
toml::array sensors;for(const auto& s:d.sensors){const auto tap=[](const MachineSensorTap& tap){return toml::table{{"part",int64_t(tap.part)},{"position",double(tap.position)},{"label",tap.label}};};sensors.push_back(toml::table{{"id",int64_t(s.id)},{"kind",machine_sensor_key(s.kind)},{"name",s.name},{"angle_offset_rad",double(s.angle_offset)},{"target",tap(s.target)},{"reference",tap(s.reference)}});}t.insert("sensors",std::move(sensors));
|
||||
t.insert("parts",std::move(parts));t.insert("connections",std::move(links));if(!file.parent_path().empty())std::filesystem::create_directories(file.parent_path());
|
||||
auto temporary=file;temporary+=".tmp";
|
||||
{std::ofstream out(temporary);out.exceptions(std::ios::failbit|std::ios::badbit);out<<t<<'\n';}
|
||||
load_machine(temporary);
|
||||
// Keep a recoverable prior version on platforms whose rename cannot replace a file.
|
||||
auto backup=file;backup+=".bak";std::error_code ec;std::filesystem::remove(backup,ec);
|
||||
const bool existed=std::filesystem::exists(file);if(existed)std::filesystem::rename(file,backup);
|
||||
try{std::filesystem::rename(temporary,file);}catch(...){if(existed)std::filesystem::rename(backup,file);throw;}
|
||||
std::filesystem::remove(backup,ec);
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::uint32_t pipe_between(MachineDocument& d,MachinePort a,MachinePort b,float diameter=.032f){
|
||||
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);p.position=multiply(add(start,end),.5f);p.points={subtract(start,p.position),subtract(end,p.position)};p.point_diameters={diameter,diameter};auto id=p.id;
|
||||
d.connect({id,0},a);d.connect({id,1},b);return id;
|
||||
}
|
||||
}
|
||||
MachineDocument machine_pump_example(){
|
||||
MachineDocument d;d.name="Vacuum pump bench";d.drive_rpm=900;d.driven=true;
|
||||
const auto tank=d.add(MachinePartKind::Chamber,{-.4f,.28f,0}).id;d.find(tank)->size={.2f,.18f,.18f};
|
||||
const auto pump=d.add(MachinePartKind::PistonPump,{0,.2f,0}).id;
|
||||
const auto air=d.add(MachinePartKind::Reservoir,{.4f,.32f,0}).id;
|
||||
pipe_between(d,{tank,1},{pump,0});pipe_between(d,{pump,1},{air,0});upgrade_machine_mechanics(d);return d;
|
||||
}
|
||||
MachineDocument machine_engine_example(){
|
||||
MachineDocument d;d.name="Single cylinder engine bench";d.drive_rpm=0;d.driven=false;
|
||||
const auto inlet=d.add(MachinePartKind::Reservoir,{-.65f,.3f,0}).id;
|
||||
const auto throttle=d.add(MachinePartKind::Throttle,{-.4f,.3f,0}).id;
|
||||
const auto chamber=d.add(MachinePartKind::Chamber,{-.2f,.3f,0}).id;d.find(chamber)->size={.1f,.08f,.08f};
|
||||
const auto cylinder=d.add(MachinePartKind::Cylinder,{0,.2f,0}).id;
|
||||
const auto collector=d.add(MachinePartKind::Chamber,{.2f,.3f,0}).id;d.find(collector)->size={.08f,.08f,.08f};
|
||||
const auto exhaust=d.add(MachinePartKind::Reservoir,{.65f,.3f,0}).id;
|
||||
const auto ecu=d.add(MachinePartKind::Ecu,{0,.5f,0}).id;d.connect({ecu,0},{cylinder,2});d.connect({ecu,0},{throttle,2});
|
||||
pipe_between(d,{inlet,0},{throttle,0});pipe_between(d,{throttle,1},{chamber,0});pipe_between(d,{chamber,1},{cylinder,0});pipe_between(d,{cylinder,1},{collector,0});pipe_between(d,{collector,1},{exhaust,0});upgrade_machine_mechanics(d);return d;
|
||||
}
|
||||
}
|
||||
1114
src/machine_builder.cpp
Normal file
1114
src/machine_builder.cpp
Normal file
File diff suppressed because it is too large
Load Diff
71
src/machine_import.cpp
Normal file
71
src/machine_import.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/Scenario.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
using namespace machine_math;
|
||||
MachineDocument machine_from_engine(const ScenarioExample& definition){
|
||||
if(definition.kind==ScenarioKind::TwoStroke)throw std::invalid_argument("Two-stroke preset import needs a crankcase/scavenge assembly; choose a four-stroke layout");
|
||||
FourStrokeScenario source(44100,definition);const auto& e=source.engine_config;const auto& g=e.geometry;const int n=e.cylinders;
|
||||
if(e.has_silenced_exhaust())throw std::invalid_argument("Choose an unsilenced layout; profiled silencer assemblies are not yet imported");
|
||||
MachineDocument d;d.name=definition.title+" / editable";if(d.name.size()>120)d.name.resize(120);d.driven=false;d.drive_rpm=e.initial_rpm;d.shaft_inertia=e.crankshaft.inertia;
|
||||
d.friction_constant=e.crankshaft.friction_constant;d.friction_viscous=e.crankshaft.friction_viscous;d.friction_quadratic=e.crankshaft.friction_quadratic;d.starter_torque=e.starter_torque;d.cells_per_meter=e.cells_per_meter>0?e.cells_per_meter:40;
|
||||
std::vector<unsigned> pipes,cylinders;
|
||||
for(std::size_t i=0;i<source.pipes.size();++i){const auto& native=source.pipes[i];const float diameter=2*std::sqrt(native.cell_areas.front()/pi);
|
||||
for(float a:native.cell_areas)if(std::abs(a-native.cell_areas.front())>1e-6f*native.cell_areas.front())throw std::invalid_argument("This layout contains a profiled duct; choose single-450, twin, flat6, or v8-h");
|
||||
auto& p=d.add(MachinePartKind::Pipe);p.name="Pipe "+std::to_string(i)+" / "+definition.id;p.position=GeometryPath{g.pipes[i].points}.position(g.pipes[i].length()*.5f);
|
||||
p.points.clear();for(auto v:g.pipes[i].points)p.points.push_back(subtract(v,p.position));p.point_diameters.assign(p.points.size(),diameter);const auto state=native.state(0);p.pressure=state.pressure;p.temperature=state.pressure/(state.rho*gas_constant);pipes.push_back(p.id);
|
||||
}
|
||||
for(int i=0;i<n;++i){auto& p=d.add(MachinePartKind::Cylinder);p.name="Cylinder "+std::to_string(i+1);p.cylinder=e.cylinder;p.valve=e.intake_valve;p.exhaust_valve=e.exhaust_valve;p.intake_profile=e.intake_cam;p.exhaust_profile=e.exhaust_cam;p.phase_degrees=e.phase_degrees[i];
|
||||
const auto& placement=g.cylinders[i];p.rotation={placement.tilt_degrees,0,0};const float height=machine_part_size(p).y,clearance=e.cylinder.stroke/(e.cylinder.compression-1),head=e.cylinder.rod+e.cylinder.stroke*.5f+clearance;
|
||||
p.position=cylinder_point(placement,{0,head-height*.5f,0});p.maximum_lift=0;for(auto point:e.intake_cam.points)p.maximum_lift=std::max(p.maximum_lift,point.lift_metres);
|
||||
const auto timing=[](const FourStrokeCylinder::CamProfile& profile){float open=0,close=720;for(std::size_t j=0;j<profile.points.size();++j)if(profile.points[j].lift_metres>0){open=j?profile.points[j-1].angle_degrees:0;break;}for(std::size_t j=profile.points.size();j-->0;)if(profile.points[j].lift_metres>0){close=j+1<profile.points.size()?profile.points[j+1].angle_degrees:720;break;}return std::pair{open,close};};
|
||||
const auto intake=timing(e.intake_cam),exhaust=timing(e.exhaust_cam);p.intake_open=intake.first;p.intake_close=intake.second;p.exhaust_open=exhaust.first;p.exhaust_close=exhaust.second;
|
||||
const auto normalize=[&](Vec3 world){auto local=machine_local_point(p,world);auto size=machine_part_size(p);return Vec3{local.x/size.x,local.y/size.y,local.z/size.z};};
|
||||
p.port_coordinates={normalize(g.pipes[1+i].points.back()),normalize(g.pipes[1+n+i].points.front())};cylinders.push_back(p.id);
|
||||
}
|
||||
const auto box=[&](const std::string& id){const auto geometry=g.volume(id);if(!geometry)throw std::invalid_argument("Missing preset geometry volume: "+id);auto& p=d.add(MachinePartKind::Chamber,geometry->center);p.name=id;p.size=geometry->size;p.temperature=id=="intake_plenum"?300.f:700.f;return p.id;};
|
||||
const auto attach=[&](int index,bool left,unsigned owner,unsigned port){d.connect({pipes.at(index),left?0u:1u},{owner,port});};
|
||||
const auto attach_box=[&](int index,bool left,unsigned owner){auto p=d.find(owner);const auto endpoint=machine_port_position(*d.find(pipes.at(index)),left?0:1);const auto local=machine_local_point(*p,endpoint);const auto s=machine_part_size(*p);unsigned port=unsigned(p->port_coordinates.size());p->port_coordinates.push_back({local.x/s.x,local.y/s.y,local.z/s.z});attach(index,left,owner,port);};
|
||||
const auto plenum=box("intake_plenum");
|
||||
for(int i=0;i<n;++i){attach_box(1+i,true,plenum);attach(1+i,false,cylinders[i],0);attach(1+n+i,true,cylinders[i],1);}
|
||||
// Insert the visible metering body into the final straight inlet segment. The new
|
||||
// body and stub replace that segment's gas volume and preserve the original route.
|
||||
const auto inlet_id=pipes.front();auto inlet=d.find(inlet_id);const auto path=machine_pipe_path(*inlet);const auto end=path.back();const float last=length(subtract(end,path[path.size()-2]));const auto direction=unit(subtract(end,path[path.size()-2]));
|
||||
// Position the newly introduced metering body far enough upstream that its
|
||||
// outlet does not create an artificial 5 mm duct. This only partitions the
|
||||
// existing inlet route; its endpoints, total length and gas volume stay fixed.
|
||||
const float body_length=std::min(.04f,last*.5f),stub_length=std::min(std::max(.005f,1/d.cells_per_meter),(last-body_length)*.5f);const float inlet_diameter=inlet->point_diameters.back();const auto start=subtract(end,multiply(direction,body_length+stub_length));inlet->points.back()=machine_local_point(*inlet,start);
|
||||
auto& metering=d.add(e.carbureted?MachinePartKind::Carburetor:MachinePartKind::Throttle,multiply(add(start,subtract(end,multiply(direction,stub_length))),.5f));const auto metering_id=metering.id;
|
||||
metering.name=e.carbureted?"Carburetor":"Electronic throttle";metering.rotation={0,-std::asin(std::clamp(direction.z,-1.f,1.f))*180/pi,std::atan2(direction.y,direction.x)*180/pi};metering.size={body_length,inlet_diameter,inlet_diameter};
|
||||
metering.diameter=e.throttle_diameter;metering.carburetor=e.fuel;metering.discharge=.7f;
|
||||
d.connect({inlet_id,1},{metering_id,0});auto& stub=d.add(MachinePartKind::Pipe);const auto stub_id=stub.id;stub.name="Inlet metering stub";stub.position={};stub.points={machine_port_position(*d.find(metering_id),1),end};stub.point_diameters={inlet_diameter,inlet_diameter};d.connect({stub_id,0},{metering_id,1});
|
||||
const auto local=machine_local_point(*d.find(plenum),end);const auto size=d.find(plenum)->size;const unsigned port=unsigned(d.find(plenum)->port_coordinates.size());d.find(plenum)->port_coordinates.push_back({local.x/size.x,local.y/size.y,local.z/size.z});d.connect({stub_id,1},{plenum,port});
|
||||
auto& ecu=d.add(MachinePartKind::Ecu,add(d.find(plenum)->position,{0,.25f,0}));const auto ecu_id=ecu.id;ecu.ecu=e.ecu;ecu.injection_enabled=!e.carbureted;ecu.name=e.carbureted?"Ignition / idle ECU":"Injection / ignition ECU";
|
||||
for(auto id:cylinders)d.connect({ecu_id,0},{id,2});
|
||||
d.connect({ecu_id,0},{metering_id,2});
|
||||
const auto mouth=[&](int index,bool left){const auto position=machine_port_position(*d.find(pipes.at(index)),left?0:1);auto& p=d.add(MachinePartKind::Reservoir,position);p.name=left?"Intake atmosphere":"Exhaust atmosphere";const auto id=p.id;attach(index,left,id,0);};mouth(0,true);
|
||||
int junction_index=0;
|
||||
const auto junction=[&](const std::vector<std::pair<int,bool>>& ends){
|
||||
if(definition.junctions.model=="volume"){const auto id=box("junction_"+std::to_string(junction_index));for(auto [index,left]:ends)attach_box(index,left,id);}
|
||||
else{const auto pos=machine_port_position(*d.find(pipes.at(ends.front().first)),ends.front().second?0:1);auto& p=d.add(MachinePartKind::Junction,pos);const auto id=p.id;p.name="Exhaust junction "+std::to_string(junction_index+1);p.junction_method=definition.junctions.model=="legacy"?2:1;p.turning_loss=definition.junctions.turning_loss;for(auto [index,left]:ends)attach(index,left,id,0);}
|
||||
++junction_index;
|
||||
};
|
||||
if(e.american_headers()){
|
||||
junction({{9,false},{10,false},{17,true}});junction({{11,false},{12,false},{18,true}});junction({{13,false},{15,false},{19,true}});junction({{14,false},{16,false},{20,true}});junction({{17,false},{18,false},{21,true}});junction({{19,false},{20,false},{22,true}});
|
||||
if(definition.junctions.h_pipe){junction({{21,false},{23,true},{25,true}});junction({{22,false},{24,true},{25,false}});}else{d.connect({pipes[21],1},{pipes[23],0});d.connect({pipes[22],1},{pipes[24],0});}
|
||||
mouth(23,false);mouth(24,false);
|
||||
}else if(e.staged_headers()){
|
||||
const int exit=1+2*n,groups=e.header_groups(),secondary=exit+(e.dual_outlets()?2:1);
|
||||
for(int group=0;group<groups;++group){std::vector<std::pair<int,bool>> ends;for(int i=0;i<n;++i)if(groups==1||(i<(n+1)/2?0:1)==group)ends.push_back({1+n+i,false});ends.push_back({secondary+group,true});junction(ends);}
|
||||
if(e.dual_outlets()){for(int bank=0;bank<2;++bank){junction({{secondary+bank,false},{exit+bank,true}});mouth(exit+bank,false);}}
|
||||
else{std::vector<std::pair<int,bool>> ends{{exit,true}};for(int group=0;group<groups;++group)ends.push_back({secondary+group,false});junction(ends);mouth(exit,false);}
|
||||
}else{
|
||||
const int exit=1+2*n;if(definition.junctions.model=="volume"){const auto collector=box("exhaust_collector");for(int i=0;i<n;++i)attach_box(1+n+i,false,collector);attach_box(exit,true,collector);}
|
||||
else{std::vector<std::pair<int,bool>> ends{{exit,true}};for(int i=0;i<n;++i)ends.push_back({1+n+i,false});junction(ends);}mouth(exit,false);
|
||||
}
|
||||
upgrade_machine_mechanics(d);validate_machine(d);return d;
|
||||
}
|
||||
}
|
||||
133
src/machine_logic.cpp
Normal file
133
src/machine_logic.cpp
Normal file
@@ -0,0 +1,133 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace {
|
||||
constexpr const char* keys[]{"boolean_constant","switch","button","number_constant","slider","number_input","and","or","not","xor","nand","nor","greater","less","equal","window","hysteresis","add","subtract","multiply","divide","minimum","maximum","absolute","clamp","gain_offset","map_range","select","boolean_to_number","sensor","command","lamp","readout","boolean_delay","number_delay","ecu"};
|
||||
constexpr const char* names[]{"Boolean constant","Toggle switch","Momentary button","Number constant","Number slider","Numeric input","AND","OR","NOT","XOR","NAND","NOR","Greater than","Less than","Equal within tolerance","Range / window","Hysteresis threshold","Add","Subtract","Multiply by scalar","Divide by scalar","Minimum","Maximum","Absolute value","Clamp","Gain / offset","Map range","Number selector","Boolean to number","Sensor reference","Actuator command","Boolean lamp","Number readout","Boolean delay","Number delay","ECU connector"};
|
||||
static_assert(std::size(keys)==logic_node_kind_count&&std::size(names)==logic_node_kind_count);
|
||||
void require(bool value,const std::string& message){if(!value)throw std::invalid_argument(message);}
|
||||
MachinePortDescriptor boolean(std::string key,std::string name,PortDirection direction,bool required=true){MachinePortDescriptor p;p.key=std::move(key);p.name=std::move(name);p.domain=PortDomain::Boolean;p.direction=direction;p.cardinality=direction==PortDirection::Output?PortCardinality::Multiple:PortCardinality::Single;p.required=required&&direction==PortDirection::Input;return p;}
|
||||
MachinePortDescriptor number(const LogicNode& n,std::string key,std::string name,PortDirection direction,bool output_type=false){auto p=boolean(std::move(key),std::move(name),direction);p.domain=PortDomain::Number;p.quantity=output_type?n.output_quantity:n.quantity;p.pressure_reference=output_type?n.output_pressure_reference:n.pressure_reference;return p;}
|
||||
}
|
||||
const char* logic_node_key(LogicNodeKind kind){require(int(kind)>=0&&int(kind)<logic_node_kind_count,"Unknown logic node kind");return keys[int(kind)];}
|
||||
const char* logic_node_name(LogicNodeKind kind){require(int(kind)>=0&&int(kind)<logic_node_kind_count,"Unknown logic node kind");return names[int(kind)];}
|
||||
const char* logic_command_key(LogicCommand command){switch(command){case LogicCommand::MotorEnable:return "motor_enable";case LogicCommand::MotorSpeed:return "motor_speed";case LogicCommand::MotorTorque:return "motor_torque";case LogicCommand::BrakeEnable:return "brake_enable";case LogicCommand::ThrottleOpening:return "throttle_opening";case LogicCommand::Starter:return "starter";}throw std::invalid_argument("Unknown logic command");}
|
||||
const char* logic_command_name(LogicCommand command){switch(command){case LogicCommand::MotorEnable:return "Motor enable";case LogicCommand::MotorSpeed:return "Motor speed target";case LogicCommand::MotorTorque:return "Motor torque target";case LogicCommand::BrakeEnable:return "Brake enable";case LogicCommand::ThrottleOpening:return "Valve opening";case LogicCommand::Starter:return "Starter enable";}throw std::invalid_argument("Unknown logic command");}
|
||||
bool logic_memory_node(LogicNodeKind kind){return kind==LogicNodeKind::BooleanDelay||kind==LogicNodeKind::NumberDelay;}
|
||||
std::vector<MachinePortDescriptor> logic_node_ports(const LogicNode& n,const MachineDocument* context){
|
||||
logic_node_key(n.kind);std::vector<MachinePortDescriptor> ports;
|
||||
const auto input=[&](const char* key,const char* name){ports.push_back(number(n,key,name,PortDirection::Input));};
|
||||
const auto output=[&](bool mapped=false){ports.push_back(number(n,"out","Output",PortDirection::Output,mapped));};
|
||||
const auto bit_input=[&](const char* key,const char* name){ports.push_back(boolean(key,name,PortDirection::Input));};
|
||||
const auto bit_output=[&]{ports.push_back(boolean("out","Output",PortDirection::Output));};
|
||||
switch(n.kind){
|
||||
case LogicNodeKind::BooleanConstant:case LogicNodeKind::Switch:case LogicNodeKind::Button:bit_output();break;
|
||||
case LogicNodeKind::NumberConstant:case LogicNodeKind::Slider:case LogicNodeKind::NumberInput:output();if(n.kind!=LogicNodeKind::NumberConstant){ports.back().minimum=n.minimum;ports.back().maximum=n.maximum;}break;
|
||||
case LogicNodeKind::And:case LogicNodeKind::Or:case LogicNodeKind::Xor:case LogicNodeKind::Nand:case LogicNodeKind::Nor:
|
||||
require(n.input_count>=2&&n.input_count<=16,"Boolean gates need 2..16 inputs");for(unsigned i=0;i<n.input_count;++i)ports.push_back(boolean("in"+std::to_string(i+1),"Input "+std::to_string(i+1),PortDirection::Input));bit_output();break;
|
||||
case LogicNodeKind::Not:case LogicNodeKind::BooleanDelay:bit_input("in","Input");bit_output();break;
|
||||
case LogicNodeKind::Greater:case LogicNodeKind::Less:case LogicNodeKind::Equal:input("a","A");input("b","B");bit_output();break;
|
||||
case LogicNodeKind::Window:case LogicNodeKind::Hysteresis:input("in","Measurement");bit_output();break;
|
||||
case LogicNodeKind::Add:case LogicNodeKind::Subtract:case LogicNodeKind::Minimum:case LogicNodeKind::Maximum:
|
||||
input("a","A");input("b","B");
|
||||
if(n.quantity==SignalQuantity::Pressure&&n.kind==LogicNodeKind::Add)ports.back().pressure_reference=PressureReference::Differential;
|
||||
output();if(n.quantity==SignalQuantity::Pressure&&n.kind==LogicNodeKind::Subtract)ports.back().pressure_reference=PressureReference::Differential;
|
||||
break;
|
||||
case LogicNodeKind::Multiply:case LogicNodeKind::Divide:input("a","Quantity");input("b",n.kind==LogicNodeKind::Divide?"Scalar divisor":"Scalar multiplier");ports.back().quantity=SignalQuantity::Scalar;ports.back().pressure_reference=PressureReference::None;output();break;
|
||||
case LogicNodeKind::Absolute:case LogicNodeKind::Clamp:case LogicNodeKind::GainOffset:case LogicNodeKind::NumberDelay:input("in","Input");output();break;
|
||||
case LogicNodeKind::MapRange:input("in","Input");output(true);break;
|
||||
case LogicNodeKind::Select:bit_input("select","Select B");input("a","A / false");input("b","B / true");output();break;
|
||||
case LogicNodeKind::BooleanToNumber:bit_input("in","Input");output();break;
|
||||
case LogicNodeKind::Sensor:{MachineSensor sensor;sensor.kind=n.sensor_kind;for(unsigned i=0;i<machine_sensor_output_count(n.sensor_kind);++i)ports.push_back(machine_sensor_output(sensor,i));break;}
|
||||
case LogicNodeKind::Ecu:{
|
||||
auto speed=number(n,"speed","Crank speed",PortDirection::Input);speed.quantity=SignalQuantity::AngularSpeed;speed.pressure_reference=PressureReference::None;speed.minimum=0;ports.push_back(speed);
|
||||
auto angle=number(n,"angle","Crank position",PortDirection::Input);angle.quantity=SignalQuantity::Angle;angle.pressure_reference=PressureReference::None;angle.minimum=0;angle.maximum=2*double(pi);ports.push_back(angle);
|
||||
if(n.ecu_strategy==SensorEcuConfig::Strategy::AlphaN){auto tps=number(n,"tps","Throttle position",PortDirection::Input);tps.quantity=SignalQuantity::Fraction;tps.pressure_reference=PressureReference::None;tps.minimum=0;tps.maximum=1;ports.push_back(tps);}
|
||||
if(n.ecu_strategy==SensorEcuConfig::Strategy::SpeedDensity){auto map=number(n,"map","MAP absolute",PortDirection::Input);map.quantity=SignalQuantity::Pressure;map.pressure_reference=PressureReference::Absolute;map.minimum=1e-9;ports.push_back(map);auto iat=number(n,"iat","Intake air temp",PortDirection::Input);iat.quantity=SignalQuantity::Temperature;iat.pressure_reference=PressureReference::None;iat.minimum=1e-9;ports.push_back(iat);}
|
||||
break;
|
||||
}
|
||||
case LogicNodeKind::Command:{
|
||||
const bool numeric=n.command==LogicCommand::MotorSpeed||n.command==LogicCommand::MotorTorque||n.command==LogicCommand::ThrottleOpening;
|
||||
auto p=boolean("in",logic_command_name(n.command),PortDirection::Input,false);
|
||||
if(numeric){p.domain=PortDomain::Number;p.quantity=n.command==LogicCommand::MotorSpeed?SignalQuantity::AngularSpeed:n.command==LogicCommand::MotorTorque?SignalQuantity::Torque:SignalQuantity::Fraction;p.default_value=0.;p.minimum=n.command==LogicCommand::ThrottleOpening?0.:-10000.;p.maximum=n.command==LogicCommand::ThrottleOpening?1.:10000.;}
|
||||
else p.default_value=false;
|
||||
if(context&&n.command==LogicCommand::MotorSpeed){const auto shaft=machine_shaft_for(*context,n.part);if(shaft&&std::any_of(context->parts.begin(),context->parts.end(),[&](const auto& part){return part.kind==MachinePartKind::Cylinder&&machine_shaft_for(*context,part.id)==shaft;}))p.minimum=0.;}
|
||||
ports.push_back(std::move(p));break;
|
||||
}
|
||||
case LogicNodeKind::Lamp:bit_input("in","State");break;
|
||||
case LogicNodeKind::Readout:input("in","Reading");break;
|
||||
}
|
||||
for(const auto& p:ports)validate_port_descriptor(p);
|
||||
return ports;
|
||||
}
|
||||
LogicNode* LogicGraph::find(std::uint32_t id){for(auto& n:nodes)if(n.id==id)return &n;return nullptr;}
|
||||
const LogicNode* LogicGraph::find(std::uint32_t id)const{for(const auto& n:nodes)if(n.id==id)return &n;return nullptr;}
|
||||
LogicNode& LogicGraph::add(LogicNodeKind kind,LogicPosition position){LogicNode n;n.id=next_id++;n.kind=kind;n.position=position;n.name=std::string(logic_node_name(kind))+" "+std::to_string(n.id);nodes.push_back(std::move(n));return nodes.back();}
|
||||
void LogicGraph::remove(std::uint32_t id){std::erase_if(links,[&](const auto& link){return link.from.node==id||link.to.node==id;});std::erase_if(nodes,[&](const auto& n){return n.id==id;});}
|
||||
void LogicGraph::disconnect(const LogicEndpoint& endpoint){std::erase_if(links,[&](const auto& link){return link.from==endpoint||link.to==endpoint;});}
|
||||
MachinePortDescriptor logic_port(const LogicGraph& graph,const LogicEndpoint& endpoint){const auto node=graph.find(endpoint.node);require(node,"Logic connection refers to a missing node");for(const auto& p:logic_node_ports(*node))if(p.key==endpoint.port)return p;throw std::invalid_argument(node->name+": unknown logic port '"+endpoint.port+"'");}
|
||||
void LogicGraph::connect(LogicEndpoint a,LogicEndpoint b){const auto pa=logic_port(*this,a),pb=logic_port(*this,b);const auto compatible=port_compatibility(pa,pb);require(compatible.allowed,compatible.message);if(pa.direction==PortDirection::Input)std::swap(a,b);auto proposed=*this;proposed.links.push_back({std::move(a),std::move(b)});validate_logic_graph(proposed);*this=std::move(proposed);}
|
||||
PortCompatibility logic_connection_preview(const LogicGraph& graph,LogicEndpoint a,LogicEndpoint b){try{auto proposed=graph;proposed.connect(std::move(a),std::move(b));return {true,"Compatible typed signal; output may fan out"};}catch(const std::exception& e){return {false,e.what()};}}
|
||||
std::vector<std::uint32_t> logic_evaluation_order(const LogicGraph& graph,std::vector<std::uint32_t>* cycle_path){
|
||||
if(cycle_path)cycle_path->clear();
|
||||
std::unordered_map<std::uint32_t,std::vector<std::uint32_t>> inputs;for(const auto& l:graph.links){const auto from=graph.find(l.from.node),to=graph.find(l.to.node);require(from&&to,"Logic wire refers to a missing node");if(!logic_memory_node(from->kind)&&!logic_memory_node(to->kind))inputs[to->id].push_back(from->id);}
|
||||
std::unordered_map<std::uint32_t,int> marks;std::vector<std::uint32_t> order,path;
|
||||
std::function<void(std::uint32_t)> visit=[&](std::uint32_t id){if(marks[id]==2)return;if(marks[id]==1){std::ostringstream message;message<<"Combinational cycle: ";const auto start=std::find(path.begin(),path.end(),id);if(cycle_path){cycle_path->assign(start,path.end());cycle_path->push_back(id);}for(auto p=start;p!=path.end();++p)message<<graph.find(*p)->name<<" -> ";message<<graph.find(id)->name;throw std::invalid_argument(message.str());}marks[id]=1;path.push_back(id);for(const auto source:inputs[id])visit(source);path.pop_back();marks[id]=2;order.push_back(id);};
|
||||
for(const auto& node:graph.nodes)if(!logic_memory_node(node.kind))visit(node.id);
|
||||
return order;
|
||||
}
|
||||
void validate_logic_graph(const LogicGraph& graph){
|
||||
require(graph.nodes.size()<=2048&&graph.links.size()<=8192,"Logic exceeds 2048 nodes / 8192 wires");std::set<std::uint32_t> ids;
|
||||
for(const auto& n:graph.nodes){require(n.id&&n.id<1000000000&&ids.insert(n.id).second,"Invalid or duplicate logic node ID");logic_node_key(n.kind);logic_command_key(n.command);machine_sensor_key(n.sensor_kind);
|
||||
require(!n.name.empty()&&n.name.size()<=120&&n.binding_label.size()<=120,"Logic names must have 1..120 characters");require(std::isfinite(n.position.x)&&std::isfinite(n.position.y)&&std::abs(n.position.x)<=1e6&&std::abs(n.position.y)<=1e6,"Invalid logic canvas position");
|
||||
require(n.part<1000000000&&n.sensor<1000000000,"Invalid logic physical binding");
|
||||
sensor_ecu_strategy_key(n.ecu_strategy);
|
||||
for(const auto value:{n.value,n.minimum,n.maximum,n.low,n.high,n.tolerance,n.gain,n.offset,n.false_value,n.true_value,n.input_low,n.input_high,n.output_low,n.output_high})require(std::isfinite(value),n.name+": numeric parameters must be finite");
|
||||
require(n.minimum<=n.maximum&&n.low<=n.high&&n.tolerance>=0,n.name+": reversed bounds or negative tolerance");
|
||||
if(n.kind==LogicNodeKind::MapRange)require(n.input_low!=n.input_high&&std::isfinite(n.input_high-n.input_low),n.name+": mapping input range must be nonzero and finite");
|
||||
if(n.kind==LogicNodeKind::Slider||n.kind==LogicNodeKind::NumberInput)require(n.value>=n.minimum&&n.value<=n.maximum,n.name+": initial value is outside the control range");
|
||||
std::set<std::string> ports;for(const auto& p:logic_node_ports(n))require(ports.insert(p.key).second,"Duplicate logic port key");
|
||||
}
|
||||
require(graph.next_id>0&&graph.next_id<1000000000&&(ids.empty()||graph.next_id>*ids.rbegin()),"Invalid next logic node ID");
|
||||
std::set<std::pair<std::uint32_t,std::string>> used;
|
||||
for(const auto& l:graph.links){const auto a=logic_port(graph,l.from),b=logic_port(graph,l.to);const auto compatible=port_compatibility(a,b);require(compatible.allowed,compatible.message);require(a.direction==PortDirection::Output&&b.direction==PortDirection::Input,"Logic wires must be output -> input");require(used.emplace(l.to.node,l.to.port).second,"A logic input has multiple drivers");}
|
||||
logic_evaluation_order(graph);
|
||||
}
|
||||
std::vector<std::string> logic_required_inputs(const LogicGraph& graph){std::vector<std::string> result;for(const auto& node:graph.nodes)for(const auto& port:logic_node_ports(node))if(port.required&&!std::any_of(graph.links.begin(),graph.links.end(),[&](const auto& link){return link.to.node==node.id&&link.to.port==port.key;}))result.push_back(node.name+" / "+port.name+": required signal is unconnected");return result;}
|
||||
bool logic_topology_equal(const LogicGraph& a,const LogicGraph& b){
|
||||
if(a.nodes.size()!=b.nodes.size()||a.links!=b.links)return false;
|
||||
for(std::size_t i=0;i<a.nodes.size();++i){const auto& x=a.nodes[i];const auto& y=b.nodes[i];if(x.id!=y.id||x.kind!=y.kind||x.quantity!=y.quantity||x.output_quantity!=y.output_quantity||x.pressure_reference!=y.pressure_reference||x.output_pressure_reference!=y.output_pressure_reference||x.input_count!=y.input_count||x.sensor!=y.sensor||x.sensor_kind!=y.sensor_kind||x.part!=y.part||x.command!=y.command||x.ecu_strategy!=y.ecu_strategy||x.minimum!=y.minimum||x.maximum!=y.maximum||x.initial!=y.initial)return false;
|
||||
if(logic_memory_node(x.kind)&&(x.value!=y.value||x.boolean!=y.boolean))return false;
|
||||
}return true;
|
||||
}
|
||||
std::string logic_binding_problem(const MachineDocument& doc,const LogicNode& node){
|
||||
if(node.kind==LogicNodeKind::Ecu){const auto part=doc.find(node.part);if(!part||part->kind!=MachinePartKind::Ecu||!part->sensor_ecu.enabled)return "Choose a sensor-driven ECU target";if(part->sensor_ecu.strategy!=node.ecu_strategy)return "ECU strategy differs from the connector's saved schema";return {};}
|
||||
if(node.kind==LogicNodeKind::Sensor){const auto sensor=doc.find_sensor(node.sensor);if(!sensor)return "Unresolved sensor reference";if(sensor->kind!=node.sensor_kind)return "Sensor kind differs from the node's saved port schema";return {};}
|
||||
if(node.kind!=LogicNodeKind::Command)return {};
|
||||
const auto part=doc.find(node.part);if(!part)return "Unresolved command target";
|
||||
switch(node.command){
|
||||
case LogicCommand::MotorEnable:if(part->kind!=MachinePartKind::DriveMotor)return "Motor enable needs a drive motor";break;
|
||||
case LogicCommand::MotorSpeed:if(part->kind!=MachinePartKind::DriveMotor||!part->drive.speed_control)return "Speed target needs a motor in ideal speed mode";break;
|
||||
case LogicCommand::MotorTorque:if(part->kind!=MachinePartKind::DriveMotor||part->drive.speed_control)return "Torque target needs a motor in torque mode";break;
|
||||
case LogicCommand::BrakeEnable:if(part->kind!=MachinePartKind::Brake)return "Brake enable needs a brake / load";break;
|
||||
case LogicCommand::ThrottleOpening:
|
||||
if(part->kind!=MachinePartKind::Throttle&&part->kind!=MachinePartKind::Carburetor)return "Opening command needs a throttle / carburetor";
|
||||
if(std::any_of(doc.links.begin(),doc.links.end(),[&](const auto& link){return link.kind==MachineConnectionKind::Control&&link.b.part==part->id;}))return "Opening already belongs to an assigned ECU";
|
||||
break;
|
||||
case LogicCommand::Starter:if(part->kind!=MachinePartKind::Shaft||part->shaft.starter_torque<=0)return "Starter command needs a shaft with declared starter torque";break;
|
||||
}return {};
|
||||
}
|
||||
void validate_machine_logic(const MachineDocument& doc){
|
||||
validate_logic_graph(doc.logic);std::set<std::pair<std::uint32_t,int>> owners;
|
||||
std::set<std::uint32_t> ecu_owners;for(const auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Ecu&&node.part)require(ecu_owners.insert(node.part).second,"An ECU has multiple signal connectors");
|
||||
for(const auto& n:doc.logic.nodes)if(n.kind==LogicNodeKind::Command&&n.part&&doc.find(n.part)){const int field=n.command==LogicCommand::MotorTorque?int(LogicCommand::MotorSpeed):int(n.command);require(owners.emplace(n.part,field).second,"Multiple signal command owners target the same actuator field");}
|
||||
}
|
||||
const LogicNode* logic_command_owner(const MachineDocument& doc,std::uint32_t part,LogicCommand command){for(const auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Command&&node.part==part&&node.command==command)return &node;return nullptr;}
|
||||
}
|
||||
32
src/machine_mechanics.cpp
Normal file
32
src/machine_mechanics.cpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
bool machine_rotational_part(MachinePartKind kind){return kind==MachinePartKind::Shaft||kind==MachinePartKind::Flywheel||kind==MachinePartKind::DriveMotor||kind==MachinePartKind::Brake;}
|
||||
std::uint32_t machine_shaft_for(const MachineDocument& document,std::uint32_t id){
|
||||
const auto part=document.find(id);if(!part)return 0;if(part->kind==MachinePartKind::Shaft)return id;
|
||||
for(const auto& link:document.links)if(link.kind==MachineConnectionKind::Mechanical){const auto other=link.a.part==id?link.b.part:link.b.part==id?link.a.part:0;if(other){const auto shaft=document.find(other);if(shaft&&shaft->kind==MachinePartKind::Shaft)return other;}}
|
||||
return 0;
|
||||
}
|
||||
std::uint32_t machine_primary_shaft(const MachineDocument& document){
|
||||
if(document.primary_shaft){const auto part=document.find(document.primary_shaft);return part&&part->kind==MachinePartKind::Shaft?part->id:0;}
|
||||
std::uint32_t found=0;for(const auto& part:document.parts)if(part.kind==MachinePartKind::Shaft){if(found)return 0;found=part.id;}return found;
|
||||
}
|
||||
bool machine_shaft_driven(const MachineDocument& document,std::uint32_t shaft){for(const auto& part:document.parts)if(part.kind==MachinePartKind::DriveMotor&&part.drive.enabled&&part.drive.speed_control&&machine_shaft_for(document,part.id)==shaft)return true;return false;}
|
||||
void upgrade_machine_mechanics(MachineDocument& document){
|
||||
if(std::any_of(document.parts.begin(),document.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Shaft;}))return;
|
||||
std::vector<std::uint32_t> attached;Vec3 center{};
|
||||
for(const auto& part:document.parts)if(part.kind==MachinePartKind::Cylinder||part.kind==MachinePartKind::PistonPump){attached.push_back(part.id);center=machine_math::add(center,part.position);}
|
||||
// Old gas-only documents can still have explicit RPM instruments or a drive.
|
||||
const bool rotational_probe=std::any_of(document.probes.begin(),document.probes.end(),[](const auto& p){return p.quantity>=MachineQuantity::Rpm;});
|
||||
if(attached.empty()&&!document.driven&&document.drive_rpm==0&&!rotational_probe)return;
|
||||
if(!attached.empty())center=machine_math::multiply(center,1.f/attached.size());
|
||||
center.y-=.15f;
|
||||
auto& shaft=document.add(MachinePartKind::Shaft,center);const auto id=shaft.id;shaft.name="Main crankshaft";shaft.shaft={document.shaft_inertia,document.drive_rpm*2*pi/60,0,document.friction_constant,document.friction_viscous,document.friction_quadratic,document.starter_torque};document.primary_shaft=id;
|
||||
for(auto part:attached)document.connect({id,0},{part,machine_port_index(*document.find(part),"shaft")});
|
||||
if(document.driven){auto& drive=document.add(MachinePartKind::DriveMotor,machine_math::add(center,{-.28f,0,0}));const auto motor=drive.id;drive.name="Bench speed drive";drive.drive.target_speed=document.drive_rpm*2*pi/60;document.connect({id,0},{motor,0});}
|
||||
if(document.load_torque>0){auto& brake=document.add(MachinePartKind::Brake,machine_math::add(center,{.28f,0,0}));const auto load=brake.id;brake.name="Bench load";brake.brake.constant=document.load_torque;document.connect({id,0},{load,0});}
|
||||
for(auto& probe:document.probes)if(probe.quantity>=MachineQuantity::Rpm&&probe.part==0)probe.part=id;
|
||||
}
|
||||
}
|
||||
71
src/machine_ports.cpp
Normal file
71
src/machine_ports.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace {
|
||||
void require(bool value,const std::string& message){if(!value)throw std::invalid_argument(message);}
|
||||
bool key_valid(const std::string& key){if(key.empty()||key.size()>64)return false;for(const auto c:key)if(!((c>='a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||c=='_'||c=='-'||c=='.'))return false;return true;}
|
||||
}
|
||||
SignalValue SignalValue::boolean(bool value,double time){return {value,std::isfinite(time)?SignalValidity::Valid:SignalValidity::Fault,time,{}};}
|
||||
SignalValue SignalValue::number(double value,double time){return {value,std::isfinite(value)&&std::isfinite(time)?SignalValidity::Valid:SignalValidity::Fault,time,{}};}
|
||||
const char* port_domain_key(PortDomain domain){switch(domain){case PortDomain::Gas:return "gas";case PortDomain::Rotational:return "rotational";case PortDomain::Translational:return "translational";case PortDomain::Boolean:return "boolean";case PortDomain::Number:return "number";case PortDomain::EcuAssignment:return "ecu_assignment";}throw std::invalid_argument("Unknown port domain");}
|
||||
const char* signal_quantity_key(SignalQuantity q){switch(q){case SignalQuantity::None:return "none";case SignalQuantity::Scalar:return "scalar";case SignalQuantity::Fraction:return "fraction";case SignalQuantity::Pressure:return "pressure";case SignalQuantity::Temperature:return "temperature";case SignalQuantity::MassFlow:return "mass_flow";case SignalQuantity::FreshAirMassFlow:return "fresh_air_mass_flow";case SignalQuantity::AngularSpeed:return "angular_speed";case SignalQuantity::Angle:return "angle";case SignalQuantity::CyclePhase:return "cycle_phase";case SignalQuantity::Torque:return "torque";case SignalQuantity::Power:return "power";case SignalQuantity::Position:return "position";case SignalQuantity::Velocity:return "velocity";case SignalQuantity::Force:return "force";case SignalQuantity::Time:return "time";case SignalQuantity::Inertia:return "inertia";}throw std::invalid_argument("Unknown signal quantity");}
|
||||
const char* signal_quantity_unit(SignalQuantity q){switch(q){case SignalQuantity::None:case SignalQuantity::Scalar:case SignalQuantity::Fraction:return "1";case SignalQuantity::Pressure:return "Pa";case SignalQuantity::Temperature:return "K";case SignalQuantity::MassFlow:case SignalQuantity::FreshAirMassFlow:return "kg/s";case SignalQuantity::AngularSpeed:return "rad/s";case SignalQuantity::Angle:case SignalQuantity::CyclePhase:return "rad";case SignalQuantity::Torque:return "N m";case SignalQuantity::Power:return "W";case SignalQuantity::Position:return "m";case SignalQuantity::Velocity:return "m/s";case SignalQuantity::Force:return "N";case SignalQuantity::Time:return "s";case SignalQuantity::Inertia:return "kg m2";}throw std::invalid_argument("Unknown signal quantity");}
|
||||
bool signal_domain(PortDomain domain){return domain==PortDomain::Boolean||domain==PortDomain::Number;}
|
||||
void validate_port_descriptor(const MachinePortDescriptor& port){
|
||||
require(key_valid(port.key),"Invalid stable port key");require(!port.name.empty()&&port.name.size()<=120,"Invalid port display name");
|
||||
port_domain_key(port.domain);signal_quantity_key(port.quantity);
|
||||
require(port.direction==PortDirection::Input||port.direction==PortDirection::Output||port.direction==PortDirection::Bidirectional,"Invalid port direction");
|
||||
require(port.cardinality==PortCardinality::Single||port.cardinality==PortCardinality::Multiple,"Invalid port cardinality");
|
||||
const bool directed=signal_domain(port.domain)||port.domain==PortDomain::EcuAssignment;
|
||||
require(directed?port.direction!=PortDirection::Bidirectional:port.direction==PortDirection::Bidirectional,"Port direction is incompatible with its domain");
|
||||
if(directed&&port.direction==PortDirection::Input)require(port.cardinality==PortCardinality::Single,"A signal input must have a single driver");
|
||||
require((port.domain==PortDomain::Number)==(port.quantity!=SignalQuantity::None),"Numeric ports need an explicit quantity; other ports cannot declare one");
|
||||
if(port.quantity==SignalQuantity::Pressure)require(port.pressure_reference==PressureReference::Absolute||port.pressure_reference==PressureReference::Gauge||port.pressure_reference==PressureReference::Differential,"Pressure signal needs an explicit reference");
|
||||
else require(port.pressure_reference==PressureReference::None,"Only pressure signals declare a pressure reference");
|
||||
require(!port.required||port.direction==PortDirection::Input,"Only inputs declare required wiring");
|
||||
require(!port.required||std::holds_alternative<std::monostate>(port.default_value),"A required input cannot also declare a default");
|
||||
require(!port.minimum||std::isfinite(*port.minimum),"Signal minimum must be finite");require(!port.maximum||std::isfinite(*port.maximum),"Signal maximum must be finite");
|
||||
require(!port.minimum||!port.maximum||*port.minimum<=*port.maximum,"Signal range is reversed");
|
||||
require(port.domain==PortDomain::Number||(!port.minimum&&!port.maximum),"Only numbers declare numeric ranges");
|
||||
if(!std::holds_alternative<std::monostate>(port.default_value)){
|
||||
require(port.direction==PortDirection::Input&&signal_domain(port.domain),"Only signal inputs declare default values");
|
||||
const auto value=std::holds_alternative<bool>(port.default_value)?SignalValue::boolean(std::get<bool>(port.default_value)):SignalValue::number(std::get<double>(port.default_value));
|
||||
require(signal_value_valid_for(value,port),"Input default has the wrong type or is outside its range");
|
||||
}
|
||||
}
|
||||
PortCompatibility port_compatibility(const MachinePortDescriptor& a,const MachinePortDescriptor& b){
|
||||
try{validate_port_descriptor(a);validate_port_descriptor(b);}catch(const std::exception& error){return {false,error.what()};}
|
||||
if(a.domain!=b.domain)return {false,std::string("Cannot connect ")+port_domain_key(a.domain)+" to "+port_domain_key(b.domain)};
|
||||
if(a.direction!=PortDirection::Bidirectional&&!(a.direction==PortDirection::Output&&b.direction==PortDirection::Input)&&!(b.direction==PortDirection::Output&&a.direction==PortDirection::Input))return {false,"Connect an output to an input"};
|
||||
if(a.domain==PortDomain::Number&&(a.quantity!=b.quantity||a.pressure_reference!=b.pressure_reference))return {false,"Number quantities or pressure references do not match; use an explicit conversion"};
|
||||
return {true,"Compatible ports"};
|
||||
}
|
||||
bool signal_value_valid_for(const SignalValue& value,const MachinePortDescriptor& port){
|
||||
if(value.validity!=SignalValidity::Valid||!std::isfinite(value.timestamp))return false;
|
||||
if(port.domain==PortDomain::Boolean)return std::holds_alternative<bool>(value.value);
|
||||
if(port.domain!=PortDomain::Number||!std::holds_alternative<double>(value.value))return false;
|
||||
const auto number=std::get<double>(value.value);return std::isfinite(number)&&(!port.minimum||number>=*port.minimum)&&(!port.maximum||number<=*port.maximum);
|
||||
}
|
||||
MachinePortDescriptor machine_port_descriptor(const MachinePart& part,unsigned index){
|
||||
require(index<machine_port_count(part),"Invalid machine port index");MachinePortDescriptor port;
|
||||
const auto kind=part.kind;
|
||||
if(machine_rotational_part(kind)||(kind==MachinePartKind::Cylinder&&index==3)||(kind==MachinePartKind::PistonPump&&index==2)){
|
||||
port.key="shaft";port.name=kind==MachinePartKind::Shaft?"Shaft attachments":"Shaft";port.domain=PortDomain::Rotational;port.cardinality=kind==MachinePartKind::Shaft?PortCardinality::Multiple:PortCardinality::Single;return port;
|
||||
}
|
||||
const bool ecu=kind==MachinePartKind::Ecu,assignment=ecu||((kind==MachinePartKind::Cylinder||kind==MachinePartKind::Throttle||kind==MachinePartKind::Carburetor)&&index==2);
|
||||
if(assignment){port.key=ecu?"assignments":"ecu_assignment";port.name=ecu?"ECU output":"ECU input";port.domain=PortDomain::EcuAssignment;port.direction=ecu?PortDirection::Output:PortDirection::Input;port.cardinality=ecu?PortCardinality::Multiple:PortCardinality::Single;return port;}
|
||||
if(kind==MachinePartKind::Pipe){port.key=index?"end":"start";port.name=index?"End":"Start";}
|
||||
else if(kind==MachinePartKind::Cylinder){port.key=index?"exhaust":"intake";port.name=index?"Exhaust":"Intake";}
|
||||
else if(kind==MachinePartKind::Throttle||kind==MachinePartKind::Carburetor||kind==MachinePartKind::CheckValve||kind==MachinePartKind::PistonPump){port.key=index?"outlet":"inlet";port.name=index?"Outlet (+X)":"Inlet (-X)";}
|
||||
else if(kind==MachinePartKind::Reservoir||kind==MachinePartKind::Cap){port.key="gas";port.name=kind==MachinePartKind::Cap?"Sealed end":"Atmosphere";}
|
||||
else if(kind==MachinePartKind::Junction&&part.junction_method){port.key="junction";port.name="Shared junction";port.cardinality=PortCardinality::Multiple;}
|
||||
else if(!part.port_keys.empty()){require(index<part.port_keys.size(),"Missing custom port key");port.key=part.port_keys[index];port.name="Gas port "+std::to_string(index+1);}
|
||||
else if(!part.port_coordinates.empty()){port.key="gas_"+std::to_string(index);port.name="Gas port "+std::to_string(index+1);}
|
||||
else{const char* keys[]{"face_x_negative","face_x_positive","face_y_negative","face_y_positive","face_z_negative","face_z_positive"};const char* names[]{"-X","+X","-Y","+Y","-Z","+Z"};require(index<6,"Invalid volume face");port.key=keys[index];port.name=names[index];}
|
||||
return port;
|
||||
}
|
||||
unsigned machine_port_index(const MachinePart& part,const std::string& key){for(unsigned i=0;i<machine_port_count(part);++i)if(machine_port_descriptor(part,i).key==key)return i;throw std::invalid_argument(part.name+": unknown port key '"+key+"'");}
|
||||
PortDomain machine_link_domain(const MachineDocument& document,const MachineLink& link){const auto a=document.find(link.a.part),b=document.find(link.b.part);require(a&&b,"Connection refers to a missing part");const auto da=machine_port_descriptor(*a,link.a.port),db=machine_port_descriptor(*b,link.b.port);require(da.domain==db.domain,"Connection crosses incompatible port domains");return da.domain;}
|
||||
}
|
||||
185
src/machine_renderer_3d.cpp
Normal file
185
src/machine_renderer_3d.cpp
Normal file
@@ -0,0 +1,185 @@
|
||||
#include "FluidSim/Desktop/MachineRenderer3D.hpp"
|
||||
#include "FluidSim/Desktop/PressurePalette.hpp"
|
||||
#include "FluidSim/Desktop/PipePath.hpp"
|
||||
#include "FluidSim/Desktop/PortAppearance.hpp"
|
||||
#include <SFML/OpenGL.hpp>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <unordered_map>
|
||||
namespace fluidsim::port::desktop {
|
||||
using namespace machine_math;
|
||||
namespace {
|
||||
void vertex(Vec3 p){glVertex3f(p.x,p.y,p.z);}
|
||||
void tint(Vec3 p,float alpha=1){glColor4f(p.x,p.y,p.z,alpha);}
|
||||
void line(Vec3 a,Vec3 b){vertex(a);vertex(b);}
|
||||
struct MeshVertex {Vec3 position,normal;};
|
||||
struct Mesh {
|
||||
std::vector<MeshVertex> vertices;
|
||||
std::vector<unsigned> indices;
|
||||
GLenum mode{GL_TRIANGLES};
|
||||
void draw(const std::vector<Vec3>* colors=nullptr)const{
|
||||
glDisableClientState(GL_TEXTURE_COORD_ARRAY);glEnableClientState(GL_VERTEX_ARRAY);glEnableClientState(GL_NORMAL_ARRAY);
|
||||
glVertexPointer(3,GL_FLOAT,sizeof(MeshVertex),vertices.data());glNormalPointer(GL_FLOAT,sizeof(MeshVertex),reinterpret_cast<const char*>(vertices.data())+offsetof(MeshVertex,normal));
|
||||
if(colors){glEnableClientState(GL_COLOR_ARRAY);glColorPointer(3,GL_FLOAT,sizeof(Vec3),colors->data());}else glDisableClientState(GL_COLOR_ARRAY);
|
||||
glDrawElements(mode,GLsizei(indices.size()),GL_UNSIGNED_INT,indices.data());
|
||||
glDisableClientState(GL_COLOR_ARRAY);glDisableClientState(GL_NORMAL_ARRAY);glDisableClientState(GL_VERTEX_ARRAY);
|
||||
}
|
||||
};
|
||||
void box(Vec3 s){
|
||||
static const Mesh mesh=[] {Mesh m;m.mode=GL_QUADS;
|
||||
const std::array<Vec3,8> v{{{-.5f,-.5f,-.5f},{.5f,-.5f,-.5f},{.5f,.5f,-.5f},{-.5f,.5f,-.5f},{-.5f,-.5f,.5f},{.5f,-.5f,.5f},{.5f,.5f,.5f},{-.5f,.5f,.5f}}};
|
||||
const int faces[6][4]{{0,3,2,1},{4,5,6,7},{0,4,7,3},{1,2,6,5},{3,7,6,2},{0,1,5,4}};
|
||||
const Vec3 normals[]{{0,0,-1},{0,0,1},{-1,0,0},{1,0,0},{0,1,0},{0,-1,0}};
|
||||
for(int f=0;f<6;++f)for(int i:faces[f]){m.indices.push_back(unsigned(m.vertices.size()));m.vertices.push_back({v[i],normals[f]});}
|
||||
return m;}();
|
||||
glPushMatrix();glScalef(s.x,s.y,s.z);mesh.draw();glPopMatrix();
|
||||
}
|
||||
void cylinder(float radius,float bottom,float top){
|
||||
static const Mesh mesh=[] {Mesh m;constexpr unsigned n=28;
|
||||
for(unsigned i=0;i<=n;++i){float a=2*pi*i/n,x=std::cos(a),z=std::sin(a);m.vertices.push_back({{x,0,z},{x,0,z}});m.vertices.push_back({{x,1,z},{x,0,z}});if(i<n){unsigned k=2*i;m.indices.insert(m.indices.end(),{k,k+1,k+2,k+2,k+1,k+3});}}
|
||||
for(unsigned side=0;side<2;++side){unsigned start=unsigned(m.vertices.size());const Vec3 nrm{0,side?1.f:-1.f,0};m.vertices.push_back({{0,float(side),0},nrm});for(unsigned i=0;i<=n;++i){float a=(side?-1:1)*2*pi*i/n;m.vertices.push_back({{std::cos(a),float(side),std::sin(a)},nrm});if(i<n)m.indices.insert(m.indices.end(),{start,start+i+1,start+i+2});}}
|
||||
return m;}();
|
||||
glPushMatrix();glTranslatef(0,bottom,0);glScalef(radius,top-bottom,radius);mesh.draw();glPopMatrix();
|
||||
}
|
||||
struct PipeVisual {
|
||||
Mesh mesh;std::array<Mesh,2> caps;
|
||||
std::vector<Vec3> points,colors;
|
||||
std::vector<float> fractions;
|
||||
std::vector<float> diameters;bool valid{};std::uint64_t frame{};
|
||||
bool matches(const MachinePart& p)const{return valid&&points==p.points&&(p.point_diameters.empty()?diameters==machine_pipe_diameters(p):diameters==p.point_diameters);}
|
||||
void build(const MachinePart& p){
|
||||
mesh={};caps={};fractions.clear();points=p.points;diameters=machine_pipe_diameters(p);
|
||||
float total=machine_pipe_length(p),distance=0;std::vector<PipeAnchor> anchors;
|
||||
for(std::size_t i=0;i<p.points.size();++i){if(i)distance+=length(subtract(p.points[i],p.points[i-1]));anchors.push_back({distance,p.points[i]});}
|
||||
PipePath path(anchors);std::vector<float> samples;
|
||||
for(std::size_t i=1;i<anchors.size();++i){const auto a=anchors[i-1].distance,b=anchors[i].distance;const int steps=std::max(1,int(std::ceil((b-a)*50)));for(int k=0;k<steps;++k)samples.push_back(std::lerp(a,b,float(k)/steps));}samples.push_back(total);
|
||||
Vec3 last_normal{};bool first=true;
|
||||
for(float d:samples){const auto center=path.position(d),axis=path.tangent(d);const auto projected=subtract(last_normal,multiply(axis,dot(last_normal,axis)));
|
||||
const Vec3 n=first||length(projected)<1e-6f?unit(cross(axis,std::abs(axis.y)<.9f?Vec3{0,1,0}:Vec3{1,0,0})):unit(projected);last_normal=n;auto b=unit(cross(axis,n));const float radius=machine_pipe_diameter(p,d)*.5f;
|
||||
const unsigned start=unsigned(mesh.vertices.size());fractions.push_back(d/total);
|
||||
for(unsigned j=0;j<24;++j){float a=2*pi*j/24;const auto nrm=add(multiply(n,std::cos(a)),multiply(b,std::sin(a)));mesh.vertices.push_back({add(center,multiply(path.miter_offset(d,nrm),radius)),nrm});
|
||||
if(!first){unsigned k=(j+1)%24,previous=start-24;mesh.indices.insert(mesh.indices.end(),{previous+j,start+j,previous+k,previous+k,start+j,start+k});}}
|
||||
first=false;
|
||||
}
|
||||
for(unsigned end=0;end<2;++end){auto& cap=caps[end];
|
||||
const auto center=p.points[end?p.points.size()-1:0],axis=multiply(path.tangent(end?total:0),end?1.f:-1.f);
|
||||
auto n=unit(cross(axis,std::abs(axis.y)<.9f?Vec3{0,1,0}:Vec3{1,0,0})),b=unit(cross(axis,n));const float radius=(end?diameters.back():diameters.front())*.5f;
|
||||
cap.vertices.push_back({center,axis});for(unsigned i=0;i<=24;++i){float a=2*pi*i/24;cap.vertices.push_back({add(center,add(multiply(n,radius*std::cos(a)),multiply(b,radius*std::sin(a)))),axis});if(i<24)cap.indices.insert(cap.indices.end(),{0,i+1,i+2});}
|
||||
}
|
||||
colors.resize(mesh.vertices.size());valid=true;
|
||||
}
|
||||
void draw(const MachinePartState* state,Vec3 base,std::array<bool,2> sealed){
|
||||
if(state&&!state->pipe_pressures.empty()){for(std::size_t i=0;i<fractions.size();++i){const auto index=std::min(state->pipe_pressures.size()-1,std::size_t(fractions[i]*state->pipe_pressures.size()));std::fill_n(colors.begin()+i*24,24,pressure_tint(state->pipe_pressures[index]));}mesh.draw(&colors);}
|
||||
else{tint(base);mesh.draw();}
|
||||
tint(base);for(unsigned end=0;end<2;++end)if(sealed[end])caps[end].draw();
|
||||
}
|
||||
};
|
||||
Vec3 part_color(MachinePartKind k){
|
||||
if(machine_rotational_part(k))return k==MachinePartKind::DriveMotor?Vec3{.26f,.46f,.70f}:k==MachinePartKind::Brake?Vec3{.65f,.33f,.28f}:Vec3{.72f,.64f,.42f};
|
||||
switch(k){case MachinePartKind::Pipe:return {.52f,.65f,.70f};case MachinePartKind::Chamber:return {.4f,.55f,.64f};case MachinePartKind::Throttle:return {.8f,.58f,.25f};case MachinePartKind::CheckValve:return {.55f,.7f,.4f};case MachinePartKind::Cylinder:return {.62f,.66f,.72f};case MachinePartKind::PistonPump:return {.55f,.45f,.72f};case MachinePartKind::Reservoir:return {.4f,.78f,.67f};case MachinePartKind::Carburetor:return {.82f,.63f,.3f};case MachinePartKind::Ecu:return {.25f,.64f,.72f};default:return {.6f,.65f,.7f};}
|
||||
}
|
||||
void draw_part(const MachinePart& p,const MachinePartState* s,bool selected,bool ghost=false,std::array<bool,2> sealed={false,false},PipeVisual* visual=nullptr){
|
||||
glPushMatrix();glTranslatef(p.position.x,p.position.y,p.position.z);glRotatef(p.rotation.z,0,0,1);glRotatef(p.rotation.y,0,1,0);glRotatef(p.rotation.x,1,0,0);
|
||||
const Vec3 base=selected?Vec3{1,.74f,.3f}:part_color(p.kind);tint(ghost?Vec3{.35f,.9f,.8f}:s&&p.kind!=MachinePartKind::Ecu&&!machine_rotational_part(p.kind)?pressure_tint(s->pressure):base);
|
||||
if(ghost){glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);glDisable(GL_LIGHTING);}
|
||||
if(machine_rotational_part(p.kind)){
|
||||
const float radius=std::min(p.size.y,p.size.z)*.5f;
|
||||
glPushMatrix();glRotatef(-90,0,0,1);cylinder(radius,-p.size.x*.5f,p.size.x*.5f);tint({.75f,.76f,.78f});cylinder(radius*.25f,-p.size.x*.58f,p.size.x*.58f);glPopMatrix();
|
||||
const float angle=s?float(std::fmod(s->shaft_angle,2*pi)):0;
|
||||
glDisable(GL_LIGHTING);tint({.95f,.82f,.48f});glBegin(GL_LINES);
|
||||
for(int i=0;i<(p.kind==MachinePartKind::Flywheel?6:1);++i){const float a=angle+i*2*pi/6;line({p.size.x*.51f,0,0},{p.size.x*.51f,radius*.85f*std::cos(a),radius*.85f*std::sin(a)});}glEnd();glEnable(GL_LIGHTING);
|
||||
if(p.kind==MachinePartKind::Brake){tint({.25f,.27f,.31f});glPushMatrix();glTranslatef(0,radius*.72f,0);box({p.size.x*.9f,p.size.y*.25f,p.size.z*.3f});glPopMatrix();}
|
||||
}
|
||||
else if(p.kind==MachinePartKind::Pipe){if(visual)visual->draw(s,base,sealed);}
|
||||
else if(p.kind==MachinePartKind::Cylinder||p.kind==MachinePartKind::PistonPump){
|
||||
const auto size=machine_part_size(p);const float top=size.y*.5f,bottom=-top,r=p.cylinder.bore*.5f;
|
||||
const float piston=top-p.cylinder.stroke/(p.cylinder.compression-1)-p.cylinder.stroke*(s?std::clamp(s->piston_fraction,0.f,1.f):.6f);
|
||||
cylinder(r,piston,top);tint({.78f,.8f,.85f});cylinder(r,piston-.002f,piston);
|
||||
glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);tint(base);cylinder(r,bottom,top);glPolygonMode(GL_FRONT_AND_BACK,ghost?GL_LINE:GL_FILL);
|
||||
for(unsigned port=0;port<2;++port){const auto pos=machine_local_port(p,port);glPushMatrix();glTranslatef(pos.x,0,pos.z);tint(port?Vec3{.9f,.5f,.32f}:Vec3{.4f,.7f,.9f});const float lift=s?(port?s->exhaust_lift:s->intake_lift):0;cylinder((port?p.cylinder.exhaust_valve_diameter:p.cylinder.intake_valve_diameter)*.5f,top-lift-.001f,top-lift);glPopMatrix();}
|
||||
}else if(p.kind==MachinePartKind::Carburetor){
|
||||
// The visible throat is the same elliptical cylinder used for gas storage.
|
||||
glPushMatrix();glRotatef(-90,0,0,1);glScalef(1,1,p.size.z/p.size.y);cylinder(p.size.y*.5f,-p.size.x*.5f,p.size.x*.5f);glPopMatrix();
|
||||
const float bowl=std::cbrt(p.carburetor.bowl_capacity/p.carburetor.fuel_density);glPushMatrix();glTranslatef(0,-p.size.y*.5f-bowl*.5f,0);tint({.68f,.51f,.2f});box({bowl,bowl,bowl});glPopMatrix();
|
||||
glDisable(GL_LIGHTING);tint({.95f,.72f,.25f});glBegin(GL_LINES);line({0,-p.size.y*.5f,0},{0,0,0});glEnd();glEnable(GL_LIGHTING);
|
||||
}else if(p.kind==MachinePartKind::Ecu){
|
||||
box(p.size);tint({.12f,.22f,.26f});glPushMatrix();glTranslatef(0,p.size.y*.55f,0);box({p.size.x*.5f,p.size.y*.15f,p.size.z*.55f});glPopMatrix();
|
||||
tint({.8f,.8f,.7f});for(int i=0;i<6;++i)for(float side:{-1.f,1.f}){glPushMatrix();glTranslatef((i-2.5f)*p.size.x*.13f,0,side*p.size.z*.55f);box({p.size.x*.07f,p.size.y*.3f,p.size.z*.12f});glPopMatrix();}
|
||||
}else if(p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Cap||(p.kind==MachinePartKind::Junction&&p.junction_method)){
|
||||
glDisable(GL_LIGHTING);glBegin(GL_LINES);
|
||||
if(p.kind==MachinePartKind::Reservoir){for(int plane=0;plane<3;++plane)for(int i=0;i<32;++i){const float a=2*pi*i/32,b=2*pi*(i+1)/32,r=.018f;const auto point=[&](float t){return plane==0?Vec3{0,r*std::cos(t),r*std::sin(t)}:plane==1?Vec3{r*std::cos(t),0,r*std::sin(t)}:Vec3{r*std::cos(t),r*std::sin(t),0};};line(point(a),point(b));}}
|
||||
else if(p.kind==MachinePartKind::Cap){line({-.015f,-.015f,0},{.015f,.015f,0});line({-.015f,.015f,0},{.015f,-.015f,0});}
|
||||
else{line({-.02f,0,0},{.02f,0,0});line({0,-.02f,0},{0,.02f,0});line({0,0,-.02f},{0,0,.02f});}glEnd();glEnable(GL_LIGHTING);
|
||||
}else{
|
||||
const bool valve=p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::CheckValve;
|
||||
if(valve)glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);
|
||||
if(p.kind==MachinePartKind::Throttle){glPushMatrix();glRotatef(-90,0,0,1);glScalef(1,1,p.size.z/p.size.y);cylinder(p.size.y*.5f,-p.size.x*.5f,p.size.x*.5f);glPopMatrix();}else box(p.size);
|
||||
glPolygonMode(GL_FRONT_AND_BACK,ghost?GL_LINE:GL_FILL);
|
||||
if(valve){glPushMatrix();if(p.kind==MachinePartKind::Throttle)glRotatef((s?s->opening:p.opening)*90,0,0,1);else glTranslatef(s?s->opening*.005f:0,0,0);
|
||||
tint({.85f,.67f,.3f});glBegin(GL_TRIANGLE_FAN);glNormal3f(1,0,0);glVertex3f(0,0,0);for(int i=0;i<=32;++i){float a=2*pi*i/32;glVertex3f(0,p.diameter*.5f*std::cos(a),p.diameter*.5f*std::sin(a));}glEnd();glPopMatrix();}
|
||||
if(p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::CheckValve){glDisable(GL_LIGHTING);tint({.9f,.75f,.35f});glBegin(GL_LINES);line({-p.size.x*.5f,p.size.y*.51f,0},{p.size.x*.5f,p.size.y*.51f,0});line({p.size.x*.5f,p.size.y*.51f,0},{p.size.x*.2f,p.size.y*.51f,.012f});line({p.size.x*.5f,p.size.y*.51f,0},{p.size.x*.2f,p.size.y*.51f,-.012f});glEnd();glEnable(GL_LIGHTING);}
|
||||
}
|
||||
if(p.injector){tint({.8f,.62f,.26f});cylinder(.006f,p.size.y*.45f,p.size.y*.5f+.025f);glPushMatrix();glTranslatef(0,p.size.y*.5f+.025f,0);tint({.2f,.25f,.3f});box({.022f,.016f,.016f});glPopMatrix();}
|
||||
if(selected&&p.kind!=MachinePartKind::Pipe){glDisable(GL_LIGHTING);glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);tint({1,.78f,.3f});box(machine_part_size(p));glPolygonMode(GL_FRONT_AND_BACK,GL_FILL);glEnable(GL_LIGHTING);}
|
||||
glPolygonMode(GL_FRONT_AND_BACK,GL_FILL);glEnable(GL_LIGHTING);glPopMatrix();
|
||||
}
|
||||
}
|
||||
struct MachineRenderer3D::Impl {
|
||||
std::unordered_map<unsigned,PipeVisual> pipes;
|
||||
PipeVisual ghost,icon_pipe;
|
||||
std::array<MachinePart,machine_part_kind_count> icon_parts;
|
||||
std::unique_ptr<sf::RenderTexture> icons;
|
||||
std::uint64_t frame{},mesh_builds{},atlas_builds{};
|
||||
Impl(){MachineDocument d;for(int i=0;i<machine_part_kind_count;++i)icon_parts[i]=d.add(MachinePartKind(i),{});icon_pipe.build(icon_parts[int(MachinePartKind::Pipe)]);}
|
||||
};
|
||||
MachineRenderer3D::MachineRenderer3D():impl_(std::make_unique<Impl>()){}
|
||||
MachineRenderer3D::~MachineRenderer3D()=default;
|
||||
MachineRenderStats MachineRenderer3D::stats()const{return {impl_->mesh_builds,impl_->atlas_builds,impl_->pipes.size()};}
|
||||
void MachineRenderer3D::draw(sf::RenderWindow& window,const MachineDocument& document,const MachineSnapshot* state,sf::FloatRect viewport,const OrbitCamera& camera,std::uint32_t selected,const MachinePart* ghost,const std::vector<std::uint32_t>& selection){
|
||||
const auto frame=++impl_->frame;
|
||||
window.pushGLStates();const auto size=window.getSize();const int x=int(viewport.position.x),y=int(size.y-viewport.position.y-viewport.size.y),w=int(viewport.size.x),h=int(viewport.size.y);
|
||||
glViewport(x,y,w,h);glEnable(GL_SCISSOR_TEST);glScissor(x,y,w,h);glClearColor(.035f,.047f,.061f,1);glClear(GL_DEPTH_BUFFER_BIT|GL_COLOR_BUFFER_BIT);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);glDisable(GL_TEXTURE_2D);glDisable(GL_BLEND);glDisable(GL_CULL_FACE);
|
||||
glEnable(GL_NORMALIZE);glEnable(GL_LIGHTING);glEnable(GL_LIGHT0);glEnable(GL_COLOR_MATERIAL);glColorMaterial(GL_FRONT_AND_BACK,GL_AMBIENT_AND_DIFFUSE);
|
||||
glMatrixMode(GL_PROJECTION);glLoadIdentity();const double near_clip=.03,half=near_clip*.41421356;glFrustum(-half*w/h,half*w/h,-half,half,near_clip,std::max(250.f,camera.distance*4));
|
||||
glMatrixMode(GL_MODELVIEW);glLoadIdentity();const GLfloat light[]{-.3f,1,1,0},ambient[]{.35f,.35f,.35f,1};glLightfv(GL_LIGHT0,GL_POSITION,light);glLightModelfv(GL_LIGHT_MODEL_AMBIENT,ambient);
|
||||
glTranslatef(0,0,-camera.distance);glRotatef(camera.pitch,1,0,0);glRotatef(camera.yaw,0,1,0);glTranslatef(-camera.target.x,-camera.target.y,-camera.target.z);
|
||||
glDisable(GL_LIGHTING);glDepthMask(GL_FALSE);glBegin(GL_LINES);const float extent=std::max(2.f,std::ceil(camera.distance/2));const float step=extent>5?.5f:.1f;
|
||||
for(float i=-extent;i<=extent+.001f;i+=step){tint({.1f,.14f,.18f});line({i,0,-extent},{i,0,extent});line({-extent,0,i},{extent,0,i});}tint({.35f,.12f,.12f});line({-extent,0,0},{extent,0,0});tint({.12f,.2f,.4f});line({0,0,-extent},{0,0,extent});glEnd();glDepthMask(GL_TRUE);glEnable(GL_LIGHTING);
|
||||
glDisable(GL_LIGHTING);glBegin(GL_LINES);for(const auto& link:document.links){const auto domain=machine_link_domain(document,link);if(domain==PortDomain::Gas)continue;const auto color=port_color(domain);glColor3ub(color.r,color.g,color.b);line(machine_port_position(*document.find(link.a.part),link.a.port),machine_port_position(*document.find(link.b.part),link.b.port));}glEnd();glEnable(GL_LIGHTING);
|
||||
for(auto& p:document.parts){const MachinePartState* s=nullptr;if(state)for(auto& v:state->parts)if(v.id==p.id){s=&v;break;}
|
||||
std::array<bool,2> sealed{true,true};if(p.kind==MachinePartKind::Pipe)for(auto& link:document.links){if(link.a.part==p.id)sealed[link.a.port]=document.find(link.b.part)->kind==MachinePartKind::Cap;else if(link.b.part==p.id)sealed[link.b.port]=document.find(link.a.part)->kind==MachinePartKind::Cap;}
|
||||
PipeVisual* visual=nullptr;if(p.kind==MachinePartKind::Pipe){auto& cache=impl_->pipes[p.id];cache.frame=frame;if(!cache.matches(p)){cache.build(p);++impl_->mesh_builds;}visual=&cache;}
|
||||
draw_part(p,s,p.id==selected||std::find(selection.begin(),selection.end(),p.id)!=selection.end(),false,sealed,visual);}
|
||||
std::erase_if(impl_->pipes,[&](const auto& entry){return entry.second.frame!=frame;});
|
||||
if(ghost){PipeVisual* visual=nullptr;if(ghost->kind==MachinePartKind::Pipe){if(!impl_->ghost.matches(*ghost)){impl_->ghost.build(*ghost);++impl_->mesh_builds;}visual=&impl_->ghost;}draw_part(*ghost,nullptr,false,true,{false,false},visual);}
|
||||
glDisable(GL_SCISSOR_TEST);window.popGLStates();
|
||||
// popGLStates restores the pre-call GL state, while SFML's state cache was
|
||||
// changed by pushGLStates. Reconcile them even if this was the first draw.
|
||||
window.resetGLStates();
|
||||
}
|
||||
namespace {
|
||||
void render_icon(sf::RenderTarget& target,const MachinePart& part,PipeVisual* pipe,sf::FloatRect viewport){
|
||||
const auto kind=part.kind;auto size=machine_part_size(part);float extent=std::max({size.x,size.y,size.z})*.8f;
|
||||
if(kind==MachinePartKind::Pipe)extent=.19f;
|
||||
if(kind==MachinePartKind::Reservoir||kind==MachinePartKind::Cap||kind==MachinePartKind::Junction)extent=.028f;
|
||||
if(kind==MachinePartKind::Carburetor)extent=.065f;
|
||||
target.pushGLStates();const auto window_size=target.getSize();const int x=int(viewport.position.x),y=int(window_size.y-viewport.position.y-viewport.size.y),w=int(viewport.size.x),h=int(viewport.size.y);
|
||||
glViewport(x,y,w,h);glEnable(GL_SCISSOR_TEST);glScissor(x,y,w,h);glClear(GL_DEPTH_BUFFER_BIT);glEnable(GL_DEPTH_TEST);glDisable(GL_TEXTURE_2D);glDisable(GL_BLEND);glDisable(GL_CULL_FACE);glEnable(GL_NORMALIZE);glEnable(GL_LIGHTING);glEnable(GL_LIGHT0);glEnable(GL_COLOR_MATERIAL);glColorMaterial(GL_FRONT_AND_BACK,GL_AMBIENT_AND_DIFFUSE);
|
||||
glMatrixMode(GL_PROJECTION);glLoadIdentity();glOrtho(-extent*w/h,extent*w/h,-extent,extent,-2,2);glMatrixMode(GL_MODELVIEW);glLoadIdentity();const GLfloat light[]{-.3f,1,1,0},ambient[]{.4f,.4f,.4f,1};glLightfv(GL_LIGHT0,GL_POSITION,light);glLightModelfv(GL_LIGHT_MODEL_AMBIENT,ambient);glRotatef(22,1,0,0);glRotatef(-32,0,1,0);
|
||||
draw_part(part,nullptr,false,false,{true,true},pipe);glDisable(GL_SCISSOR_TEST);target.popGLStates();
|
||||
}
|
||||
}
|
||||
void MachineRenderer3D::draw_icon(sf::RenderWindow& window,MachinePartKind kind,sf::FloatRect viewport){
|
||||
constexpr unsigned width=64,height=48;
|
||||
if(!impl_->icons){
|
||||
sf::ContextSettings settings;settings.depthBits=24;settings.majorVersion=2;settings.minorVersion=1;
|
||||
impl_->icons=std::make_unique<sf::RenderTexture>(sf::Vector2u{width*machine_part_kind_count,height},settings);
|
||||
impl_->icons->clear(sf::Color::Transparent);
|
||||
for(int i=0;i<machine_part_kind_count;++i)render_icon(*impl_->icons,impl_->icon_parts[i],i==int(MachinePartKind::Pipe)?&impl_->icon_pipe:nullptr,{{float(i*width),0},{float(width),float(height)}});
|
||||
impl_->icons->display();++impl_->atlas_builds;window.resetGLStates();
|
||||
}
|
||||
sf::Sprite sprite(impl_->icons->getTexture(),{{int(kind)*int(width),0},{int(width),int(height)}});
|
||||
sprite.setPosition(viewport.position);sprite.setScale({viewport.size.x/width,viewport.size.y/height});window.draw(sprite);
|
||||
}
|
||||
}
|
||||
77
src/machine_sensors.cpp
Normal file
77
src/machine_sensors.cpp
Normal file
@@ -0,0 +1,77 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace {
|
||||
constexpr const char* keys[]{"pressure","differential_pressure","gas_temperature","mass_flow","crank","throttle_position"};
|
||||
constexpr const char* names[]{"Pressure / MAP","Differential pressure","Gas temperature / IAT","Total mass flow","Crank speed / position","Throttle position"};
|
||||
static_assert(std::size(keys)==machine_sensor_kind_count&&std::size(names)==machine_sensor_kind_count);
|
||||
void require(bool condition,const std::string& message){if(!condition)throw std::invalid_argument(message);}
|
||||
}
|
||||
const char* machine_sensor_key(MachineSensorKind kind){require(int(kind)>=0&&int(kind)<machine_sensor_kind_count,"Unknown sensor kind");return keys[int(kind)];}
|
||||
const char* machine_sensor_name(MachineSensorKind kind){require(int(kind)>=0&&int(kind)<machine_sensor_kind_count,"Unknown sensor kind");return names[int(kind)];}
|
||||
const char* machine_sensor_status_name(MachineSensorStatus status){switch(status){case MachineSensorStatus::Valid:return "Valid / ideal pickup";case MachineSensorStatus::MissingHost:return "Unresolved host";case MachineSensorStatus::IncompatibleHost:return "Incompatible host";case MachineSensorStatus::Fault:return "Measurement fault";}return "Unknown sensor status";}
|
||||
unsigned machine_sensor_output_count(MachineSensorKind kind){machine_sensor_key(kind);return kind==MachineSensorKind::Crank?3:1;}
|
||||
MachinePortDescriptor machine_sensor_output(const MachineSensor& sensor,unsigned index){
|
||||
require(index<machine_sensor_output_count(sensor.kind),"Unknown sensor output");
|
||||
MachinePortDescriptor p;p.key="value";p.name=machine_sensor_name(sensor.kind);p.domain=PortDomain::Number;p.direction=PortDirection::Output;p.cardinality=PortCardinality::Multiple;
|
||||
switch(sensor.kind){
|
||||
case MachineSensorKind::Pressure:p.quantity=SignalQuantity::Pressure;p.pressure_reference=PressureReference::Absolute;p.minimum=0;break;
|
||||
case MachineSensorKind::DifferentialPressure:p.quantity=SignalQuantity::Pressure;p.pressure_reference=PressureReference::Differential;break;
|
||||
case MachineSensorKind::GasTemperature:p.quantity=SignalQuantity::Temperature;p.minimum=0;break;
|
||||
case MachineSensorKind::MassFlow:p.quantity=SignalQuantity::MassFlow;break;
|
||||
case MachineSensorKind::ThrottlePosition:p.quantity=SignalQuantity::Fraction;p.minimum=0;p.maximum=1;break;
|
||||
case MachineSensorKind::Crank:
|
||||
if(index==0){p.key="speed";p.name="Angular speed";p.quantity=SignalQuantity::AngularSpeed;}
|
||||
else if(index==1){p.key="angle";p.name="Crank angle";p.quantity=SignalQuantity::Angle;p.minimum=0;p.maximum=2*double(pi);}
|
||||
else {p.key="synchronized";p.name="Ideal pickup synchronized";p.domain=PortDomain::Boolean;}
|
||||
break;
|
||||
}
|
||||
validate_port_descriptor(p);return p;
|
||||
}
|
||||
bool machine_sensor_supported(const MachinePart& part,MachineSensorKind kind,bool reference){
|
||||
if(reference)return kind==MachineSensorKind::DifferentialPressure&&machine_quantity_supported(part,MachineQuantity::Pressure);
|
||||
switch(kind){
|
||||
case MachineSensorKind::Pressure:case MachineSensorKind::DifferentialPressure:return machine_quantity_supported(part,MachineQuantity::Pressure);
|
||||
case MachineSensorKind::GasTemperature:return machine_quantity_supported(part,MachineQuantity::Temperature);
|
||||
// A volume has no unique through-flow direction. Use a named pipe tap.
|
||||
case MachineSensorKind::MassFlow:return part.kind==MachinePartKind::Pipe;
|
||||
case MachineSensorKind::Crank:return part.kind==MachinePartKind::Shaft;
|
||||
case MachineSensorKind::ThrottlePosition:return part.kind==MachinePartKind::Throttle||part.kind==MachinePartKind::Carburetor;
|
||||
}return false;
|
||||
}
|
||||
MachineSensorStatus machine_sensor_binding(const MachineDocument& doc,const MachineSensor& sensor){
|
||||
const auto target=doc.find(sensor.target.part);if(!target)return MachineSensorStatus::MissingHost;
|
||||
if(!machine_sensor_supported(*target,sensor.kind))return MachineSensorStatus::IncompatibleHost;
|
||||
if(sensor.kind==MachineSensorKind::DifferentialPressure){const auto reference=doc.find(sensor.reference.part);if(!reference)return MachineSensorStatus::MissingHost;if(!machine_sensor_supported(*reference,sensor.kind,true))return MachineSensorStatus::IncompatibleHost;}
|
||||
return MachineSensorStatus::Valid;
|
||||
}
|
||||
std::optional<Vec3> machine_sensor_position(const MachineDocument& doc,const MachineSensorTap& tap){
|
||||
const auto host=doc.find(tap.part);if(!host)return {};
|
||||
if(host->kind!=MachinePartKind::Pipe)return machine_world_point(*host,{0,machine_part_size(*host).y*.5f,0});
|
||||
return GeometryPath{machine_pipe_path(*host)}.position(machine_pipe_length(*host)*tap.position);
|
||||
}
|
||||
MachineSensor* MachineDocument::find_sensor(std::uint32_t id){for(auto& s:sensors)if(s.id==id)return &s;return nullptr;}
|
||||
const MachineSensor* MachineDocument::find_sensor(std::uint32_t id)const{for(const auto& s:sensors)if(s.id==id)return &s;return nullptr;}
|
||||
void MachineDocument::remove_sensor(std::uint32_t id){for(auto& node:logic.nodes)if(node.kind==LogicNodeKind::Sensor&&node.sensor==id){if(const auto sensor=find_sensor(id))node.binding_label=sensor->name;node.sensor=0;}std::erase_if(sensors,[&](const auto& s){return s.id==id;});}
|
||||
MachineSensor& MachineDocument::add_sensor(MachineSensorKind kind,std::uint32_t host,float position){
|
||||
const auto part=find(host);require(part&&machine_sensor_supported(*part,kind),"This part does not support that sensor");
|
||||
require(std::isfinite(position)&&position>=0&&position<=1,"Sensor pipe position must be in 0..1");
|
||||
MachineSensor sensor;sensor.id=next_sensor_id++;sensor.kind=kind;sensor.target={host,position,part->name};sensor.name=std::string(machine_sensor_name(kind))+" / "+part->name;if(sensor.name.size()>120)sensor.name.resize(120);
|
||||
sensors.push_back(std::move(sensor));return sensors.back();
|
||||
}
|
||||
void validate_machine_sensors(const MachineDocument& doc){
|
||||
require(doc.sensors.size()<=1024,"Machine exceeds 1024 sensor attachments");std::set<std::uint32_t> ids;
|
||||
for(const auto& s:doc.sensors){
|
||||
require(s.id>0&&s.id<1000000000&&ids.insert(s.id).second,"Invalid or duplicate sensor ID");machine_sensor_key(s.kind);
|
||||
require(!s.name.empty()&&s.name.size()<=120,"Sensor name must have 1..120 characters");
|
||||
for(const auto* tap:{&s.target,&s.reference})require(tap->part<1000000000&&tap->label.size()<=120&&std::isfinite(tap->position)&&tap->position>=0&&tap->position<=1,"Invalid sensor host or pipe position");
|
||||
require(std::isfinite(s.angle_offset)&&std::abs(s.angle_offset)<=1000000,"Invalid crank pickup offset");
|
||||
for(unsigned i=0;i<machine_sensor_output_count(s.kind);++i)validate_port_descriptor(machine_sensor_output(s,i));
|
||||
}
|
||||
require(doc.next_sensor_id>0&&doc.next_sensor_id<1000000000&&(ids.empty()||doc.next_sensor_id>*ids.rbegin()),"Invalid next sensor ID");
|
||||
}
|
||||
}
|
||||
382
src/machine_simulation.cpp
Normal file
382
src/machine_simulation.cpp
Normal file
@@ -0,0 +1,382 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/Fluid.hpp"
|
||||
#include "FluidSim/PipeProfile.hpp"
|
||||
#include "FluidSim/Solver.hpp"
|
||||
#include "FluidSim/Ecu.hpp"
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <unordered_map>
|
||||
#include <tuple>
|
||||
|
||||
namespace fluidsim::port {
|
||||
using namespace machine_math;
|
||||
namespace {
|
||||
struct Actuator final:Component {
|
||||
std::function<void(float)> advance;
|
||||
std::vector<Port*> ports()override{return {};}
|
||||
void prepare_step(float dt)override{advance(dt);}
|
||||
void update_state(float)override{}
|
||||
};
|
||||
FourStrokeCylinder::CamProfile cam(float open,float close,float lift){
|
||||
FourStrokeCylinder::CamProfile c;c.points.push_back({0,0});
|
||||
if(open>0)c.points.push_back({open,0});
|
||||
for(int i=1;i<32;++i){const float f=float(i)/32,s=std::sin(pi*f);c.points.push_back({std::lerp(open,close,f),lift*s*s});}
|
||||
c.points.push_back({close,0});if(close<720)c.points.push_back({720,0});return c;
|
||||
}
|
||||
float bore_area(const MachinePart& p){return pi*.25f*p.cylinder.bore*p.cylinder.bore;}
|
||||
float pump_volume(const MachinePart& p,float angle){
|
||||
const float radius=p.cylinder.stroke*.5f,rod=p.cylinder.rod,s=std::sin(angle);
|
||||
const float travel=radius*(1-std::cos(angle))+rod-std::sqrt(rod*rod-radius*radius*s*s);
|
||||
return bore_area(p)*(p.cylinder.stroke/(p.cylinder.compression-1)+travel);
|
||||
}
|
||||
}
|
||||
struct MachineSimulation::Impl {
|
||||
struct Gate;
|
||||
struct ShaftRuntime {std::uint32_t id{};std::unique_ptr<Crankshaft> timing;double previous_speed{};};
|
||||
struct Part {
|
||||
const MachinePart* definition{};
|
||||
int pipe{-1};
|
||||
std::unique_ptr<Volume0D> volume;
|
||||
std::unique_ptr<FourStrokeCylinder> cylinder;
|
||||
Ecu ecu;
|
||||
std::unique_ptr<SensorEcu> sensor_ecu;
|
||||
std::unique_ptr<FuelInjector> injector;
|
||||
Part* injector_ecu{};
|
||||
CrankIgnitionClock ignition_clock;
|
||||
Part* controller{};
|
||||
ShaftRuntime* shaft{};double gas_torque{};
|
||||
std::unique_ptr<Carburetor> carburetor;
|
||||
bool fuel_source{};
|
||||
std::optional<bool> signal_enable,signal_starter;
|
||||
std::optional<double> signal_target;
|
||||
std::optional<float> signal_opening;
|
||||
Gate* inlet{};
|
||||
FourStrokeCylinder::CamProfile intake_cam,exhaust_cam;
|
||||
};
|
||||
struct Gate {Part* part;unsigned port;int pipe;bool left;float area{},maximum{};Port* component_port{};float throat_area{};};
|
||||
struct Mouth {std::size_t index;std::unique_ptr<OutdoorAudio> audio;};
|
||||
struct SensorTap {Part* host{};int cell{};};
|
||||
struct Sensor {const MachineSensor* definition{};SensorTap target,reference;MachineSensorState state;std::array<MachinePortDescriptor,3> outputs;};
|
||||
MachineDocument document;
|
||||
std::vector<Pipe1D> pipes;
|
||||
BoundarySystem boundaries{pipes};
|
||||
Solver solver{pipes,boundaries};
|
||||
Actuator actuator;
|
||||
MechanicalSystem mechanics;
|
||||
std::vector<std::unique_ptr<ShaftRuntime>> shafts;
|
||||
std::unordered_map<std::uint32_t,ShaftRuntime*> shaft_by_id;
|
||||
std::vector<std::unique_ptr<Part>> parts;
|
||||
std::unordered_map<std::uint32_t,Part*> by_id;
|
||||
std::deque<Gate> gates;
|
||||
std::vector<Part*> cylinders,controllers,pumps,carburetors;
|
||||
std::vector<Part*> motors,brakes;
|
||||
std::vector<Gate*> cylinder_gates,throttle_gates,check_gates;
|
||||
std::vector<Mouth> mouths;
|
||||
std::vector<Sensor> sensors;
|
||||
std::unordered_map<std::uint32_t,std::size_t> sensor_by_id;
|
||||
std::unique_ptr<MachineLogicRuntime> logic;
|
||||
float dt{},pedal{.2f};bool starter{};
|
||||
double time{};
|
||||
explicit Impl(const MachineDocument& d,int rate):document(d),dt(1.f/std::max(1,rate)){
|
||||
validate_machine(document);if(document.parts.empty()&&document.logic.nodes.empty())throw std::invalid_argument("Place some parts or logic nodes before testing");
|
||||
for(const auto& p:document.parts)if(p.kind==MachinePartKind::Shaft){
|
||||
bool engine=false;for(const auto& c:document.parts)if(c.kind==MachinePartKind::Cylinder&&machine_shaft_for(document,c.id)==p.id)engine=true;
|
||||
const auto& q=p.shaft;mechanics.add_shaft(p.id,{q.inertia,q.initial_speed,q.initial_angle,q.friction_constant,q.friction_viscous,q.friction_quadratic,engine});
|
||||
auto runtime=std::make_unique<ShaftRuntime>();runtime->id=p.id;runtime->timing=std::make_unique<Crankshaft>();runtime->timing->angle=runtime->timing->previous_angle=std::fmod(q.initial_angle,4*pi);runtime->timing->omega=q.initial_speed;runtime->previous_speed=q.initial_speed;shaft_by_id[p.id]=runtime.get();shafts.push_back(std::move(runtime));
|
||||
}
|
||||
for(const auto& p:document.parts){const auto shaft=machine_shaft_for(document,p.id);
|
||||
if((machine_rotational_part(p.kind)&&p.kind!=MachinePartKind::Shaft)||p.kind==MachinePartKind::Cylinder||p.kind==MachinePartKind::PistonPump){if(!shaft)throw std::invalid_argument(p.name+": connect its mechanical port to a shaft");}
|
||||
if(p.kind==MachinePartKind::Flywheel)mechanics.add_flywheel(p.id,shaft,p.shaft.inertia);
|
||||
else if(p.kind==MachinePartKind::DriveMotor)mechanics.add_drive(p.id,shaft,{p.drive.speed_control?DriveMode::Speed:DriveMode::Torque,p.drive.speed_control?p.drive.target_speed:p.drive.torque,p.drive.enabled});
|
||||
else if(p.kind==MachinePartKind::Brake)mechanics.add_brake(p.id,shaft,{p.brake.constant,p.brake.viscous,p.brake.quadratic,p.brake.enabled});
|
||||
}
|
||||
|
||||
actuator.advance=[this](float){update_check_valves();};solver.minimum_substeps=1;
|
||||
parts.reserve(d.parts.size());
|
||||
for(auto& p:document.parts){
|
||||
auto runtime=std::make_unique<Part>();auto& r=*runtime;r.definition=&p;const auto shaft_id=machine_shaft_for(document,p.id);if(shaft_id)r.shaft=shaft_by_id.at(shaft_id);
|
||||
if(p.kind==MachinePartKind::Pipe){
|
||||
// Integrate the per-point profile over uniform arclength cells.
|
||||
// Edited route knots do not force tiny fluid substeps.
|
||||
r.pipe=int(pipes.size());const auto profile=machine_pipe_sections(p);
|
||||
pipes.push_back(make_uniform_profiled_pipe(profile,1/d.cells_per_meter));auto& pipe=pipes.back();pipe.solver=d.solver;
|
||||
for(auto& cell:pipe.cells){cell.rho=p.pressure/(gas_constant*p.temperature);cell.energy=p.pressure/pipe_gm1;}
|
||||
}else if(p.kind==MachinePartKind::Cylinder){
|
||||
r.cylinder=std::make_unique<FourStrokeCylinder>();auto& c=*r.cylinder;static_cast<CylinderConfig&>(c)=p.cylinder;
|
||||
c.phase=p.phase_degrees*pi/180;r.intake_cam=p.intake_profile.points.empty()?cam(p.intake_open,p.intake_close,p.maximum_lift):p.intake_profile;r.exhaust_cam=p.exhaust_profile.points.empty()?cam(p.exhaust_open,p.exhaust_close,p.maximum_lift):p.exhaust_profile;
|
||||
c.set_valve_profiles(r.intake_cam,p.valve,r.exhaust_cam,p.exhaust_valve);
|
||||
r.ecu.ignition_map={{0,p.cylinder.spark_advance},{20000,p.cylinder.spark_advance}};r.ecu.stoichiometric_afr=r.ecu.power_afr=p.cylinder.afr;
|
||||
r.ecu.rev_limit_enabled=false;r.ecu.overrun_fuel_cut_enabled=false;r.ecu.overrun_ignition_cut_enabled=false;c.ecu=&r.ecu;
|
||||
c.transported_fuel=true; // No implicit injector. Only an assigned injection ECU can enable dosing.
|
||||
c.initialize(*r.shaft->timing);c.volume=c.compute_volume(r.shaft->timing->angle+c.phase);c.air_mass=p.pressure*c.volume/(gas_constant*p.temperature);c.energy=p.pressure*c.volume/(gamma-1);c.update_state(0);solver.add_component(c);
|
||||
}else if(p.kind==MachinePartKind::Ecu){static_cast<EcuConfig&>(r.ecu)=p.ecu;if(p.sensor_ecu.enabled)r.sensor_ecu=std::make_unique<SensorEcu>(machine_sensor_ecu_config(p));}
|
||||
else if(!machine_rotational_part(p.kind)&&p.kind!=MachinePartKind::Reservoir&&p.kind!=MachinePartKind::Cap&&!(p.kind==MachinePartKind::Junction&&p.junction_method)){
|
||||
const float v=p.kind==MachinePartKind::PistonPump?pump_volume(p,r.shaft->timing->angle+p.phase_degrees*pi/180):machine_part_volume(p);
|
||||
r.volume=std::make_unique<Volume0D>(v,p.pressure,p.temperature);solver.add_component(*r.volume);
|
||||
if(p.kind==MachinePartKind::Carburetor)r.carburetor=std::make_unique<Carburetor>(p.diameter,p.carburetor);
|
||||
}
|
||||
if(p.injector){const auto& c=*p.injector;FuelInjectorConfig config;config.supply=c.finite_supply?FuelInjectorConfig::Supply::FiniteRail:FuelInjectorConfig::Supply::IdealRail;config.maximum_mass_flow=c.maximum_flow;config.initial_fuel_mass=c.initial_mass;config.fuel_temperature=c.temperature;r.injector=std::make_unique<FuelInjector>(config);}
|
||||
if(r.cylinder)cylinders.push_back(&r);
|
||||
if(r.carburetor)carburetors.push_back(&r);
|
||||
if(p.kind==MachinePartKind::Ecu)controllers.push_back(&r);
|
||||
if(p.kind==MachinePartKind::PistonPump)pumps.push_back(&r);
|
||||
if(p.kind==MachinePartKind::DriveMotor)motors.push_back(&r);
|
||||
if(p.kind==MachinePartKind::Brake)brakes.push_back(&r);
|
||||
by_id[p.id]=&r;parts.push_back(std::move(runtime));
|
||||
}
|
||||
if(pipes.empty()&&shafts.empty()&&document.logic.nodes.empty())throw std::invalid_argument("Add a pipe, mechanical shaft or logic graph to test");
|
||||
for(auto& link:document.links)if(machine_link_domain(document,link)==PortDomain::EcuAssignment){auto target=by_id.at(link.b.part),control=by_id.at(link.a.part);target->controller=control;
|
||||
if(target->cylinder){if(control->shaft&&control->shaft!=target->shaft)throw std::invalid_argument(control->definition->name+": controlled cylinders must share a shaft");control->shaft=target->shaft;target->cylinder->ecu=&control->ecu;target->cylinder->transported_fuel=bool(control->sensor_ecu)||!control->definition->injection_enabled;target->fuel_source=!control->sensor_ecu&&control->definition->injection_enabled;if(control->sensor_ecu)target->cylinder->ignition_command=FourStrokeCylinder::IgnitionCommand{};}
|
||||
}
|
||||
// Legacy carb metering can use the unique shaft reached through its
|
||||
// intake network; a sensor-driven ECU will replace this ideal reference.
|
||||
const auto gas_reference=[&](Part* source){std::vector<std::uint32_t> visited{source->definition->id};ShaftRuntime* found=nullptr;for(std::size_t i=0;i<visited.size();++i)for(const auto& link:document.links)if(machine_link_domain(document,link)==PortDomain::Gas){const auto other=link.a.part==visited[i]?link.b:link.b.part==visited[i]?link.a:MachinePort{};if(!other.part||std::find(visited.begin(),visited.end(),other.part)!=visited.end())continue;auto target=by_id.at(other.part);if(target->cylinder){if(other.port==0){if(found&&found!=target->shaft)return static_cast<ShaftRuntime*>(nullptr);found=target->shaft;}continue;}visited.push_back(other.part);}return found;};
|
||||
for(auto controller:controllers)if(!controller->shaft){for(const auto& link:document.links)if(link.kind==MachineConnectionKind::Control&&link.a.part==controller->definition->id){auto reference=gas_reference(by_id.at(link.b.part));if(reference){if(controller->shaft&&controller->shaft!=reference)throw std::invalid_argument("ECU targets refer to multiple shafts");controller->shaft=reference;}}}
|
||||
for(auto& part:parts)if(part->injector){const auto found=by_id.find(part->definition->injector->ecu);if(found==by_id.end()||!found->second->sensor_ecu||found->second->definition->sensor_ecu.strategy==SensorEcuConfig::Strategy::Ignition)throw std::invalid_argument(part->definition->name+": bind its injector to a sensor-driven EFI ECU");part->injector_ecu=found->second;}
|
||||
for(auto controller:controllers)if(controller->sensor_ecu){unsigned targets=0,doses=0;for(auto target:cylinders)if(target->controller==controller)++targets;for(const auto& part:parts)if(part->injector_ecu==controller)doses+=part->definition->injector->cylinders;if(!targets)throw std::invalid_argument(controller->definition->name+": assign controlled cylinders");if(controller->definition->sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition&&doses!=targets)throw std::invalid_argument(controller->definition->name+": injector cylinder doses must match assigned cylinders");}
|
||||
for(auto carb:carburetors)carb->shaft=carb->controller?carb->controller->shaft:gas_reference(carb);
|
||||
// This reachability flag is diagnostic only; carburetor fuel still has to travel through the gas solver.
|
||||
for(auto r:cylinders)r->fuel_source=machine_has_fuel_source(document,r->definition->id);
|
||||
std::vector<std::array<bool,2>> claimed(pipes.size(),{false,false});
|
||||
std::unordered_map<std::uint32_t,std::vector<BoundarySystem::JunctionFace>> junctions;
|
||||
const auto face=[&](MachinePort e){auto& r=*by_id.at(e.part);const auto path=machine_pipe_path(*r.definition);const auto axis=e.port?subtract(path[path.size()-2],path.back()):subtract(path[1],path.front());const auto dir=unit(axis);return BoundarySystem::JunctionFace{r.pipe,e.port==0,{dir.x,dir.y,dir.z}};};
|
||||
for(auto& link:document.links){
|
||||
if(machine_link_domain(document,link)!=PortDomain::Gas)continue;
|
||||
MachinePort a=link.a,b=link.b;if(by_id.at(a.part)->pipe<0)std::swap(a,b);
|
||||
auto& pipe_part=*by_id.at(a.part);auto& other=*by_id.at(b.part);const auto& p=*other.definition;
|
||||
const auto& pipe=pipes[pipe_part.pipe];const bool left=a.port==0;
|
||||
const float area=left?pipe.cell_areas.front():pipe.cell_areas.back();claimed[pipe_part.pipe][a.port]=true;
|
||||
if(other.pipe>=0){
|
||||
claimed[other.pipe][b.port]=true;
|
||||
auto ends={face(a),face(b)};
|
||||
boundaries.add_junction(ends,link.kind==MachineConnectionKind::Legacy?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic,link.loss);
|
||||
}else if(p.kind==MachinePartKind::Cap)boundaries.add_closed_end(pipe_part.pipe,left);
|
||||
else if(p.kind==MachinePartKind::Reservoir){
|
||||
boundaries.add_radiating_open_end(pipe_part.pipe,left,p.pressure,area,p.temperature);
|
||||
OutdoorAudioConfig config;config.intake_position=machine_port_position(p,b.port);config.ground_y=-.02f;config.listener={1,.7f,1.5f};
|
||||
auto audio=std::make_unique<OutdoorAudio>(rate,config);audio->set_scene(d.scene);audio->set_source_enabled(1,false);audio->set_source_enabled(2,false);audio->set_source_enabled(3,false);
|
||||
mouths.push_back({mouths.size(),std::move(audio)});
|
||||
}else if(p.kind==MachinePartKind::Junction&&p.junction_method)junctions[p.id].push_back(face(a));
|
||||
else{
|
||||
Port* component_port=other.cylinder?(b.port?&other.cylinder->exhaust:&other.cylinder->intake):&other.volume->create_port();
|
||||
gates.push_back({&other,b.port,pipe_part.pipe,left,area,area,component_port});auto& gate=gates.back();
|
||||
if(other.cylinder)cylinder_gates.push_back(&gate);
|
||||
else if((p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor)&&b.port==0){gate.throat_area=std::min(area,pi*.25f*p.diameter*p.diameter);throttle_gates.push_back(&gate);if(other.carburetor)other.inlet=&gate;}
|
||||
else if(p.kind==MachinePartKind::CheckValve||p.kind==MachinePartKind::PistonPump){const float diameter=p.kind==MachinePartKind::PistonPump?(b.port?p.cylinder.exhaust_valve_diameter:p.cylinder.intake_valve_diameter):p.diameter;
|
||||
const int count=p.kind==MachinePartKind::PistonPump?(b.port?p.cylinder.exhaust_valve_count:p.cylinder.intake_valve_count):1;gate.throat_area=std::min(area,pi*.25f*diameter*diameter*count);check_gates.push_back(&gate);}
|
||||
const float cd=link.discharge*((p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::CheckValve||other.cylinder||p.kind==MachinePartKind::PistonPump)?p.discharge:1.f);
|
||||
if(link.kind==MachineConnectionKind::Inertance)boundaries.add_orifice_with_inertance(*component_port,pipe_part.pipe,left,gate.area,cd,link.inertance_length,link.loss);
|
||||
else boundaries.add_orifice(*component_port,pipe_part.pipe,left,gate.area,cd);
|
||||
}
|
||||
}
|
||||
for(auto& [id,faces]:junctions){auto& p=*by_id.at(id)->definition;
|
||||
if(faces.size()==1)boundaries.add_closed_end(faces.front().pipe,faces.front().left);
|
||||
else boundaries.add_junction(faces,p.junction_method==2?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic,p.turning_loss);
|
||||
}
|
||||
for(std::size_t i=0;i<pipes.size();++i)for(unsigned end=0;end<2;++end)if(!claimed[i][end])boundaries.add_closed_end(int(i),end==0);
|
||||
boundaries.validate_topology();
|
||||
if(!check_gates.empty())solver.add_component(actuator);
|
||||
const auto sensor_tap=[&](const MachineSensorTap& tap){
|
||||
SensorTap result;if(const auto found=by_id.find(tap.part);found!=by_id.end())result.host=found->second;
|
||||
if(result.host&&result.host->pipe>=0){const auto& pipe=pipes[result.host->pipe];double length=0;for(float dx:pipe.cell_lengths)length+=dx;const auto position=length*tap.position;double end=0;
|
||||
result.cell=int(pipe.cells.size())-1;for(std::size_t i=0;i<pipe.cells.size();++i){end+=pipe.cell_lengths[i];if(position<end){result.cell=int(i);break;}}
|
||||
}return result;
|
||||
};
|
||||
sensors.reserve(document.sensors.size());for(const auto& s:document.sensors){Sensor sensor;sensor.definition=&s;sensor.target=sensor_tap(s.target);sensor.reference=sensor_tap(s.reference);sensor.state.id=s.id;sensor.state.count=machine_sensor_output_count(s.kind);sensor.state.status=machine_sensor_binding(document,s);for(unsigned i=0;i<sensor.state.count;++i)sensor.outputs[i]=machine_sensor_output(s,i);sensors.push_back(std::move(sensor));}
|
||||
sample_sensors();
|
||||
for(std::size_t i=0;i<sensors.size();++i)sensor_by_id.emplace(sensors[i].state.id,i);
|
||||
logic=std::make_unique<MachineLogicRuntime>(document,[this](std::uint32_t id){const auto found=sensor_by_id.find(id);return found==sensor_by_id.end()?MachineSensorState{}:sensors[found->second].state;});
|
||||
for(const auto controller:controllers)if(controller->sensor_ecu&&!std::any_of(logic->ecu_inputs().begin(),logic->ecu_inputs().end(),[&](const auto& input){return input.part==controller->definition->id;}))throw std::invalid_argument(controller->definition->name+": add its ECU connector in Logic");
|
||||
latch_logic(false);sample_sensors();
|
||||
}
|
||||
float throttle_opening(const Part& part)const{const auto& p=*part.definition;return part.signal_opening?*part.signal_opening:part.controller?part.controller->ecu.throttle_opening():p.pedal_control?pedal:p.opening;}
|
||||
void latch_logic(bool evaluate=true){
|
||||
if(evaluate)logic->evaluate(time,dt);
|
||||
for(const auto& command:logic->commands()){auto& target=*by_id.at(command.part);const bool valid=command.value.validity==SignalValidity::Valid;
|
||||
switch(command.command){
|
||||
case LogicCommand::MotorEnable:case LogicCommand::BrakeEnable:target.signal_enable=valid&&std::get<bool>(command.value.value);break;
|
||||
case LogicCommand::MotorSpeed:case LogicCommand::MotorTorque:target.signal_target=valid?std::get<double>(command.value.value):0.;break;
|
||||
case LogicCommand::ThrottleOpening:target.signal_opening=valid?float(std::get<double>(command.value.value)):0.f;break;
|
||||
case LogicCommand::Starter:target.signal_starter=valid&&std::get<bool>(command.value.value);break;
|
||||
}
|
||||
}
|
||||
for(auto motor:motors)if(motor->signal_enable||motor->signal_target){const auto& p=*motor->definition;mechanics.set_drive(p.id,{p.drive.speed_control?DriveMode::Speed:DriveMode::Torque,motor->signal_target.value_or(p.drive.speed_control?p.drive.target_speed:p.drive.torque),motor->signal_enable.value_or(p.drive.enabled)});}
|
||||
for(auto brake:brakes)if(brake->signal_enable){const auto& p=*brake->definition;mechanics.set_brake(p.id,{p.brake.constant,p.brake.viscous,p.brake.quadratic,*brake->signal_enable});}
|
||||
if(evaluate)for(const auto& input:logic->ecu_inputs()){
|
||||
auto& controller=*by_id.at(input.part);auto speed=input.speed;const auto measured=ecu_crank_sample(speed,input.angle,time);if(!measured||!controller.shaft||measured->shaft!=controller.shaft->id)speed.validity=SignalValidity::Fault;const auto& state=controller.sensor_ecu->sample(SensorEcuMeasurements{speed,input.angle,input.tps,input.map,input.iat},time);
|
||||
for(auto target:cylinders)if(target->controller==&controller){
|
||||
const auto crank=ecu_crank_sample(input.speed,input.angle,time);
|
||||
const bool bound=crank&&crank->shaft==target->shaft->id;
|
||||
CrankIgnitionClock::Result event;
|
||||
if(bound&&!state.sensor_fault)event=target->ignition_clock.sample(input.speed,input.angle,time,state.spark_advance,target->definition->ignition_tdc_degrees);else target->ignition_clock.reset();
|
||||
const bool cut=!bound||state.sensor_fault||state.rev_limited||!event.valid;
|
||||
target->cylinder->ignition_command=FourStrokeCylinder::IgnitionCommand{event.valid&&event.sparks>0,cut};
|
||||
}
|
||||
}
|
||||
}
|
||||
void deliver_injectors(){for(auto& target:parts)if(target->injector){const auto& state=target->injector_ecu->sensor_ecu->state();auto command=SignalValue::number(state.fuel_mass_flow*target->definition->injector->cylinders,time);if(state.sensor_fault)command.validity=SignalValidity::Fault;target->injector->deliver(*target->volume,command,time,dt);}}
|
||||
double gas_sensor_value(const SensorTap& tap,MachineSensorKind kind)const{
|
||||
const auto& r=*tap.host;
|
||||
if(r.pipe>=0){const auto& pipe=pipes[r.pipe];const auto cell=pipe.state(tap.cell);if(kind==MachineSensorKind::MassFlow)return double(cell.rho)*cell.velocity*pipe.cell_areas[tap.cell];if(kind==MachineSensorKind::GasTemperature)return cell.pressure/(cell.rho*gas_constant);return cell.pressure;}
|
||||
if(r.cylinder)return kind==MachineSensorKind::GasTemperature?r.cylinder->temperature():r.cylinder->pressure;
|
||||
if(r.volume)return kind==MachineSensorKind::GasTemperature?r.volume->temperature():r.volume->pressure();
|
||||
return kind==MachineSensorKind::GasTemperature?r.definition->temperature:r.definition->pressure;
|
||||
}
|
||||
void sample_sensors(){
|
||||
for(auto& sensor:sensors){const auto& definition=*sensor.definition;auto& state=sensor.state;
|
||||
// Bindings cannot change during Test; a numeric fault may recover.
|
||||
const bool bound=state.status==MachineSensorStatus::Valid||state.status==MachineSensorStatus::Fault;
|
||||
state.timing_shaft=0;state.unwrapped_angle=0;
|
||||
for(unsigned i=0;i<state.count;++i){state.outputs[i]=definition.kind==MachineSensorKind::Crank&&i==2?SignalValue::boolean(false,time):SignalValue::number(0,time);state.outputs[i].validity=SignalValidity::Missing;}
|
||||
if(!bound)continue;
|
||||
state.status=MachineSensorStatus::Valid;
|
||||
const auto& host=*sensor.target.host;
|
||||
if(definition.kind==MachineSensorKind::Crank){const auto& shaft=mechanics.shaft(host.definition->id);state.timing_shaft=shaft.id;state.unwrapped_angle=shaft.angle+definition.angle_offset;double angle=std::fmod(state.unwrapped_angle,2*double(pi));if(angle<0)angle+=2*double(pi);
|
||||
state.outputs[0]=SignalValue::number(shaft.speed,time);state.outputs[1]=SignalValue::number(angle,time);state.outputs[2]=SignalValue::boolean(true,time);
|
||||
const SignalTiming timing{definition.id,shaft.id,time,state.unwrapped_angle,shaft.speed};
|
||||
for(auto& output:state.outputs)output.timing=timing;
|
||||
}else if(definition.kind==MachineSensorKind::ThrottlePosition)state.outputs[0]=SignalValue::number(throttle_opening(host),time);
|
||||
else{auto value=gas_sensor_value(sensor.target,definition.kind);if(definition.kind==MachineSensorKind::DifferentialPressure)value-=gas_sensor_value(sensor.reference,MachineSensorKind::Pressure);state.outputs[0]=SignalValue::number(value,time);}
|
||||
for(unsigned i=0;i<state.count;++i)if(!signal_value_valid_for(state.outputs[i],sensor.outputs[i]))state.status=MachineSensorStatus::Fault;
|
||||
if(state.status==MachineSensorStatus::Fault){state.timing_shaft=0;for(unsigned i=0;i<state.count;++i)state.outputs[i].validity=SignalValidity::Fault;}
|
||||
}
|
||||
}
|
||||
// Shaft, ECU, cams and combustion advance at the audio sample rate, as in
|
||||
// FourStrokeScenario. Gas transport and pressure-operated checks retain CFL substeps.
|
||||
void advance_controls(float sub_dt){
|
||||
const auto primary=machine_primary_shaft(document);
|
||||
for(const auto& shaft:shafts){const auto& part=*by_id.at(shaft->id);const bool enabled=part.signal_starter.value_or(starter&&shaft->id==primary);if(enabled){const auto& state=mechanics.shaft(shaft->id);if(state.speed>=0&&state.speed<200)mechanics.add_torque(shaft->id,part.definition->shaft.starter_torque);}}
|
||||
mechanics.step(sub_dt);
|
||||
for(auto& shaft:shafts){auto& crank=*shaft->timing;shaft->previous_speed=crank.omega;const auto& state=mechanics.shaft(shaft->id);crank.previous_angle=crank.angle;crank.angle=std::fmod(state.angle,4*pi);crank.omega=float(state.speed);crank.inertia=float(state.inertia);const float output=float(state.external_torque+state.friction_torque);crank.record_output(output,output*crank.omega);}
|
||||
const auto rpm_of=[](const Part* p){return p->shaft?float(p->shaft->timing->omega*60/(2*pi)):0.f;};
|
||||
for(auto r:controllers)if(!r->sensor_ecu)r->ecu.step(sub_dt,pedal,rpm_of(r));
|
||||
for(auto r:cylinders){if(!r->controller)r->ecu.step(sub_dt,pedal,rpm_of(r));r->cylinder->pre_step(*r->shaft->timing,sub_dt,pedal);r->gas_torque=r->shaft->timing->take_torque();mechanics.add_torque(r->shaft->id,r->gas_torque);}
|
||||
for(auto r:carburetors){const auto& p=*r->definition;float flow=0,upstream=p.pressure,density=ambient_density;if(auto g=r->inlet){const auto& pipe=pipes[g->pipe];const auto state=pipe.state(g->left?0:int(pipe.cells.size()-1));upstream=state.pressure;density=state.rho;flow=std::max(0.f,g->component_port->mass_flow);}
|
||||
const float opening=throttle_opening(*r);
|
||||
r->carburetor->step(sub_dt,pedal,opening,rpm_of(r),flow,density,upstream,r->volume->pressure());r->volume->add_fuel_mass(r->carburetor->fuel_flow()*sub_dt);
|
||||
}
|
||||
for(auto r:pumps){const auto& p=*r->definition;
|
||||
const float theta=r->shaft->timing->angle+p.phase_degrees*pi/180,radius=p.cylinder.stroke*.5f,sine=std::sin(theta),cosine=std::cos(theta),rod=p.cylinder.rod;
|
||||
const float travel_derivative=radius*sine+radius*radius*sine*cosine/std::sqrt(std::max(1e-12f,rod*rod-radius*radius*sine*sine));
|
||||
r->gas_torque=(r->volume->pressure()-101325)*bore_area(p)*travel_derivative;mechanics.add_torque(r->shaft->id,r->gas_torque);
|
||||
r->volume->volume_change_rate=(pump_volume(p,theta)-r->volume->volume())/sub_dt;
|
||||
}
|
||||
for(auto g:cylinder_gates)g->area=std::min(g->maximum,g->part->cylinder->valve_area(0,g->port==0));
|
||||
for(auto g:throttle_gates){const float opening=throttle_opening(*g->part);g->area=g->throat_area*(1-std::cos(opening*pi*.5f));}
|
||||
}
|
||||
void update_check_valves(){
|
||||
for(auto g:check_gates){const auto& p=*g->part->definition;const auto& pipe=pipes[g->pipe];const auto state=pipe.state(g->left?0:int(pipe.cells.size()-1));
|
||||
const float internal=g->part->volume->pressure(),delta=g->port==0?state.pressure-internal:internal-state.pressure;
|
||||
g->area=delta>p.cracking_pressure?g->throat_area:0;
|
||||
}
|
||||
}
|
||||
AudioFrame process(){latch_logic();advance_controls(dt);deliver_injectors();solver.step(dt);time+=dt;sample_sensors();AudioFrame result;
|
||||
for(auto& mouth:mouths){const auto s=mouth.audio->process(ambient_density*boundaries.open_end_volume_flow(mouth.index),0);result.left+=s.left;result.right+=s.right;}
|
||||
return result;
|
||||
}
|
||||
double rotational_reading(std::uint32_t part,MachineQuantity quantity)const{
|
||||
if(!part)part=machine_primary_shaft(document);
|
||||
if(!part)return std::numeric_limits<double>::quiet_NaN();
|
||||
const auto& r=*by_id.at(part);if(!r.shaft)return std::numeric_limits<double>::quiet_NaN();const auto& shaft=mechanics.shaft(r.shaft->id);if(quantity==MachineQuantity::Rpm)return shaft.speed*60/(2*pi);
|
||||
double torque=shaft.net_torque,power=torque*(shaft.speed+r.shaft->previous_speed)*.5;
|
||||
if(r.definition->kind==MachinePartKind::DriveMotor){const auto& drive=mechanics.drive(part);torque=drive.torque;power=drive.power;}
|
||||
else if(r.definition->kind==MachinePartKind::Brake){const auto& brake=mechanics.brake(part);torque=brake.torque;power=brake.power;}
|
||||
else if(r.definition->kind==MachinePartKind::Flywheel){torque*=r.definition->shaft.inertia/shaft.inertia;power*=r.definition->shaft.inertia/shaft.inertia;}
|
||||
else if(r.cylinder||r.definition->kind==MachinePartKind::PistonPump){torque=r.gas_torque;power=torque*(shaft.angle-shaft.previous_angle)/dt;}
|
||||
return quantity==MachineQuantity::Torque?torque:power;
|
||||
}
|
||||
MachineMeasurement measure()const{
|
||||
MachineMeasurement result;result.time=time;result.count=document.probes.size();result.values.fill(std::numeric_limits<float>::quiet_NaN());
|
||||
for(std::size_t i=0;i<document.probes.size();++i){const auto& probe=document.probes[i];auto& value=result.values[i];auto& status=result.states[i];
|
||||
if(probe.quantity>=MachineQuantity::Rpm){value=float(rotational_reading(probe.part,probe.quantity));if(probe.generated_output){const auto shaft=machine_shaft_for(document,probe.part?probe.part:machine_primary_shaft(document));if(shaft){const auto& timing=*shaft_by_id.at(shaft)->timing;value=probe.quantity==MachineQuantity::Torque?timing.average_torque:timing.average_power;}}continue;}
|
||||
const auto& r=*by_id.at(probe.part);float pressure=r.definition->pressure,temperature=r.definition->temperature,flow=0,air=0,fuel=0;
|
||||
if(r.pipe>=0){const auto& p=pipes[r.pipe];const int at=std::min(int(p.cells.size())-1,int(probe.position*p.cells.size()));const auto s=p.state(at);pressure=s.pressure;temperature=s.pressure/(s.rho*gas_constant);flow=s.rho*s.velocity*p.cell_areas[at];fuel=s.fuel_equivalent;air=1-fuel;}
|
||||
else if(r.cylinder){pressure=r.cylinder->pressure;temperature=r.cylinder->temperature();flow=r.cylinder->intake.mass_flow+r.cylinder->exhaust.mass_flow;fuel=r.cylinder->fuel_equivalent_mass;air=r.cylinder->total_mass()-fuel;}
|
||||
else if(r.volume){pressure=r.volume->pressure();temperature=r.volume->temperature();fuel=r.volume->fuel_equivalent_fraction();air=1-fuel;for(const auto& g:gates)if(g.part==&r)flow+=g.component_port->mass_flow;}
|
||||
if(probe.quantity==MachineQuantity::Pressure)value=pressure;else if(probe.quantity==MachineQuantity::Temperature)value=temperature;else if(probe.quantity==MachineQuantity::MassFlow)value=flow;
|
||||
else if(probe.quantity==MachineQuantity::AirFuelRatio||probe.quantity==MachineQuantity::Lambda){
|
||||
if(fuel<=1e-12f)status=MachineReadingState::NoFuel;
|
||||
else{const float afr=air/fuel;if(!std::isfinite(afr)||afr>machine_afr_reading_limit)status=MachineReadingState::LeanLimit;else value=probe.quantity==MachineQuantity::Lambda?afr/probe.reference_afr:afr;}
|
||||
}else if(probe.quantity==MachineQuantity::ChargeAfr&&r.cylinder){const auto& cylinder=*r.cylinder;
|
||||
if(!cylinder.transported_fuel&&cylinder.ecu&&cylinder.ecu->fuel_cut())status=MachineReadingState::FuelCut;
|
||||
else if(!cylinder.charge_sampled)status=MachineReadingState::AwaitingCharge;
|
||||
else if(!std::isfinite(cylinder.charge_afr))status=MachineReadingState::NoFuel;
|
||||
else if(cylinder.charge_afr>machine_afr_reading_limit)status=MachineReadingState::LeanLimit;
|
||||
else value=cylinder.charge_afr;
|
||||
}
|
||||
}return result;
|
||||
}
|
||||
void apply_tuning(const MachineDocument& proposed){
|
||||
validate_machine(proposed);
|
||||
if(proposed.driven!=document.driven||proposed.drive_rpm!=document.drive_rpm||proposed.shaft_inertia!=document.shaft_inertia||proposed.load_torque!=document.load_torque||proposed.friction_constant!=document.friction_constant||proposed.friction_viscous!=document.friction_viscous||proposed.friction_quadratic!=document.friction_quadratic||proposed.starter_torque!=document.starter_torque)throw std::invalid_argument("Shared-shaft settings are legacy: edit the explicit shaft, drive or brake part");
|
||||
const auto frozen=[](bool match){if(!match)throw std::invalid_argument("This property requires returning to Build");};
|
||||
frozen(proposed.parts.size()==document.parts.size()&&proposed.links.size()==document.links.size()&&proposed.cells_per_meter==document.cells_per_meter&&proposed.solver==document.solver&&proposed.scene==document.scene&&proposed.sensors==document.sensors&&logic_topology_equal(document.logic,proposed.logic));
|
||||
|
||||
for(std::size_t i=0;i<document.links.size();++i){const auto& a=document.links[i];const auto& b=proposed.links[i];frozen(a.a==b.a&&a.b==b.b&&a.kind==b.kind&&a.discharge==b.discharge&&a.inertance_length==b.inertance_length&&a.loss==b.loss);}
|
||||
const auto cam_equal=[](const auto& a,const auto& b){return a.points.size()==b.points.size()&&std::equal(a.points.begin(),a.points.end(),b.points.begin(),[](const auto& x,const auto& y){return x.angle_degrees==y.angle_degrees&&x.lift_metres==y.lift_metres;});};
|
||||
const auto valve=[](const auto& v){return std::tuple{v.mass_kg,v.spring_rate_n_per_m,v.damping_n_s_per_m,v.seat_force_n,v.maximum_lift_m,v.seat_restitution,v.cam_restitution,v.stop_restitution};};
|
||||
const auto cylinder_geometry=[](const auto& c){return std::tuple{c.bore,c.stroke,c.rod,c.compression,c.intake_valve_diameter,c.exhaust_valve_diameter,c.intake_valve_count,c.exhaust_valve_count};};
|
||||
for(std::size_t i=0;i<document.parts.size();++i){const auto& a=document.parts[i];const auto& b=proposed.parts[i];
|
||||
frozen(a.id==b.id&&a.kind==b.kind&&a.position==b.position&&a.rotation==b.rotation&&a.size==b.size&&a.points==b.points&&machine_pipe_diameters(a)==machine_pipe_diameters(b)&&a.port_coordinates==b.port_coordinates&&a.port_keys==b.port_keys);
|
||||
if(machine_rotational_part(a.kind))frozen(a.shaft.inertia==b.shaft.inertia&&a.shaft.initial_speed==b.shaft.initial_speed&&a.shaft.initial_angle==b.shaft.initial_angle);
|
||||
frozen(a.diameter==b.diameter&&a.outlet_diameter==b.outlet_diameter&&a.pressure==b.pressure&&a.temperature==b.temperature&&a.discharge==b.discharge&&a.phase_degrees==b.phase_degrees&&a.junction_method==b.junction_method&&a.turning_loss==b.turning_loss);
|
||||
frozen(a.sensor_ecu==b.sensor_ecu&&a.ignition_tdc_degrees==b.ignition_tdc_degrees);
|
||||
frozen(a.injection_enabled==b.injection_enabled);
|
||||
frozen(a.injector==b.injector);
|
||||
frozen(cylinder_geometry(a.cylinder)==cylinder_geometry(b.cylinder)&&a.maximum_lift==b.maximum_lift&&a.intake_open==b.intake_open&&a.intake_close==b.intake_close&&a.exhaust_open==b.exhaust_open&&a.exhaust_close==b.exhaust_close&&valve(a.valve)==valve(b.valve)&&valve(a.exhaust_valve)==valve(b.exhaust_valve)&&cam_equal(a.intake_profile,b.intake_profile)&&cam_equal(a.exhaust_profile,b.exhaust_profile));
|
||||
}
|
||||
logic->apply_tuning(proposed);document.logic=proposed.logic;
|
||||
document.primary_shaft=proposed.primary_shaft;document.probes=proposed.probes;
|
||||
for(std::size_t i=0;i<document.parts.size();++i){auto& p=document.parts[i];const auto& q=proposed.parts[i];p.carburetor=q.carburetor;p.ecu=q.ecu;p.cylinder=q.cylinder;p.opening=q.opening;p.pedal_control=q.pedal_control;p.cracking_pressure=q.cracking_pressure;p.injection_enabled=q.injection_enabled;p.shaft=q.shaft;p.drive=q.drive;p.brake=q.brake;
|
||||
if(p.kind==MachinePartKind::Shaft)mechanics.set_friction(p.id,p.shaft.friction_constant,p.shaft.friction_viscous,p.shaft.friction_quadratic);
|
||||
if(p.kind==MachinePartKind::DriveMotor)mechanics.set_drive(p.id,{p.drive.speed_control?DriveMode::Speed:DriveMode::Torque,p.drive.speed_control?p.drive.target_speed:p.drive.torque,p.drive.enabled});
|
||||
if(p.kind==MachinePartKind::Brake)mechanics.set_brake(p.id,{p.brake.constant,p.brake.viscous,p.brake.quadratic,p.brake.enabled});
|
||||
|
||||
auto& r=*by_id.at(p.id);if(r.carburetor)static_cast<CarburetorConfig&>(*r.carburetor)=p.carburetor;
|
||||
if(p.kind==MachinePartKind::Ecu){static_cast<EcuConfig&>(r.ecu)=p.ecu;if(r.sensor_ecu)r.sensor_ecu->apply_calibration(machine_sensor_ecu_config(p));}
|
||||
if(r.cylinder){static_cast<CylinderConfig&>(*r.cylinder)=p.cylinder;if(!r.controller){r.ecu.ignition_map={{0,p.cylinder.spark_advance},{20000,p.cylinder.spark_advance}};r.ecu.stoichiometric_afr=r.ecu.power_afr=p.cylinder.afr;}}
|
||||
}
|
||||
for(auto r:cylinders){r->cylinder->transported_fuel=!(r->controller&&r->controller->definition->injection_enabled);r->fuel_source=machine_has_fuel_source(document,r->definition->id);}
|
||||
sample_sensors();
|
||||
}
|
||||
MachineSnapshot snapshot()const{
|
||||
MachineSnapshot s;s.time=time;s.primary_shaft=machine_primary_shaft(document);s.rpm=float(rotational_reading(s.primary_shaft,MachineQuantity::Rpm));s.torque=float(rotational_reading(s.primary_shaft,MachineQuantity::Torque));s.power=float(rotational_reading(s.primary_shaft,MachineQuantity::Power));s.substeps=solver.substep_count();for(const auto& shaft:shafts)s.shafts.push_back(mechanics.shaft(shaft->id));
|
||||
for(const auto& sensor:sensors)s.sensors.push_back(sensor.state);
|
||||
s.logic=logic->snapshot();
|
||||
for(auto& r:parts){const auto& p=*r->definition;MachinePartState v;v.id=p.id;v.pressure=p.pressure;v.temperature=p.temperature;v.volume=machine_part_volume(p);
|
||||
v.signal_controlled=r->signal_enable.has_value()||r->signal_target.has_value()||r->signal_opening.has_value()||r->signal_starter.has_value();
|
||||
if(p.kind==MachinePartKind::DriveMotor){v.actuator_enabled=r->signal_enable.value_or(p.drive.enabled);v.command_target=r->signal_target.value_or(p.drive.speed_control?p.drive.target_speed:p.drive.torque);}
|
||||
else if(p.kind==MachinePartKind::Brake)v.actuator_enabled=r->signal_enable.value_or(p.brake.enabled);
|
||||
if(r->shaft){const auto& shaft=mechanics.shaft(r->shaft->id);v.shaft=shaft.id;v.shaft_angle=shaft.angle;v.shaft_speed=shaft.speed;v.rotational_inertia=p.kind==MachinePartKind::Flywheel?p.shaft.inertia:shaft.inertia;v.kinetic_energy=.5*v.rotational_inertia*shaft.speed*shaft.speed;v.torque=rotational_reading(p.id,MachineQuantity::Torque);v.power=rotational_reading(p.id,MachineQuantity::Power);if(p.kind==MachinePartKind::DriveMotor)v.mechanical_work=mechanics.drive(p.id).work;else if(p.kind==MachinePartKind::Brake)v.mechanical_loss=mechanics.brake(p.id).loss;else if(p.kind==MachinePartKind::Shaft){v.mechanical_work=shaft.external_work+shaft.drive_work;v.mechanical_loss=shaft.friction_loss+shaft.brake_loss;}}
|
||||
if(p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor)v.opening=throttle_opening(*r);
|
||||
if(p.kind==MachinePartKind::Ecu){v.fuel_cut=r->ecu.fuel_cut();v.ignition_cut=r->ecu.ignition_cut();v.idle_control=r->ecu.idle_active();v.afr=r->ecu.target_afr();v.opening=r->ecu.throttle_opening();}
|
||||
if(r->sensor_ecu){const auto& state=r->sensor_ecu->state();v.ignition_cut=state.sensor_fault||state.rev_limited;v.fuel_cut=v.ignition_cut&&p.sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition;v.sensor_fault=state.sensor_fault;v.rev_limited=state.rev_limited;v.spark_advance=float(state.spark_advance);v.idle_control=false;v.afr=0;v.opening=0;v.fuel_flow=float(state.fuel_mass_flow);}
|
||||
if(r->carburetor){v.fuel_flow=r->carburetor->fuel_flow();v.afr=r->carburetor->indicated_afr();}
|
||||
if(r->injector){const auto& state=r->injector->state();v.fuel_flow=float(state.mass_flow);v.fuel_cut=state.command_fault||state.empty;v.injector_fault=state.command_fault;v.injector_empty=state.empty;v.injector_remaining=state.remaining_mass;v.injector_delivered=state.delivered_mass;v.fuel_mass=r->volume->fuel_mass();}
|
||||
if(r->pipe>=0){const auto& pipe=pipes[r->pipe];s.cells+=pipe.cells.size();s.positivity_repairs+=pipe.positivity_repairs;v.pressure=0;v.volume=0;const auto shortest=pipe.minimum_cell_length();if(s.minimum_cell_length==0||shortest<s.minimum_cell_length){s.minimum_cell_length=shortest;s.minimum_cell_part=p.id;}
|
||||
for(std::size_t i=0;i<pipe.cells.size();++i){auto state=pipe.state(int(i));v.pipe_pressures.push_back(state.pressure);v.pressure+=state.pressure;v.flow+=state.rho*state.velocity*pipe.cell_areas[i];v.volume+=pipe.cell_areas[i]*pipe.cell_lengths[i];}
|
||||
v.pressure/=pipe.cells.size();v.flow/=pipe.cells.size();
|
||||
}else if(r->volume){v.pressure=r->volume->pressure();v.temperature=r->volume->temperature();v.volume=r->volume->volume();
|
||||
if(p.kind==MachinePartKind::PistonPump)v.piston_fraction=(v.volume/bore_area(p)-p.cylinder.stroke/(p.cylinder.compression-1))/p.cylinder.stroke;
|
||||
}else if(r->cylinder){const auto& c=*r->cylinder;v.pressure=c.pressure;v.temperature=c.temperature();v.volume=c.volume;v.piston_fraction=(c.volume-c.clearance_volume())/c.swept_volume();v.intake_lift=c.intake_lift();v.exhaust_lift=c.exhaust_lift();v.fuel_mass=c.fuel_mass;v.combustion=c.combustion_active;v.burn_fraction=c.burn_fraction;v.fuel_source=r->fuel_source;v.valve_float=c.valve_floating();v.fuel_cut=c.ecu->fuel_cut();v.ignition_cut=c.ecu->ignition_cut();}
|
||||
if(r->cylinder&&r->cylinder->ignition_command)v.ignition_cut=r->cylinder->ignition_command->cut;
|
||||
if(r->cylinder&&r->controller&&r->controller->sensor_ecu)v.fuel_cut=v.ignition_cut&&r->controller->definition->sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition;
|
||||
for(auto& g:gates)if(g.part==r.get()&&g.component_port){v.flow+=g.component_port->mass_flow;if(p.kind==MachinePartKind::CheckValve)v.opening=std::max(v.opening,g.area>0?1.f:0.f);}
|
||||
s.parts.push_back(std::move(v));
|
||||
}return s;
|
||||
}
|
||||
};
|
||||
MachineSimulation::MachineSimulation(const MachineDocument& d,int rate):impl_(std::make_unique<Impl>(d,rate)){}
|
||||
MachineSimulation::~MachineSimulation()=default;
|
||||
AudioFrame MachineSimulation::process(){return impl_->process();}
|
||||
void MachineSimulation::set_controls(float pedal,bool starter){impl_->pedal=std::clamp(pedal,0.f,1.f);impl_->starter=starter;}
|
||||
void MachineSimulation::set_listener(AudioListener listener){for(auto& mouth:impl_->mouths)mouth.audio->set_listener(listener);}
|
||||
MachineSnapshot MachineSimulation::snapshot()const{return impl_->snapshot();}
|
||||
MachineMeasurement MachineSimulation::measure()const{return impl_->measure();}
|
||||
MachineSensorState MachineSimulation::sense(std::uint32_t id)const{for(const auto& sensor:impl_->sensors)if(sensor.state.id==id)return sensor.state;throw std::out_of_range("Unknown machine sensor");}
|
||||
void MachineSimulation::set_logic_control(std::uint32_t node,bool value){impl_->logic->set_manual(node,value);}
|
||||
void MachineSimulation::set_logic_control(std::uint32_t node,double value){impl_->logic->set_manual(node,value);}
|
||||
void MachineSimulation::release_logic_buttons(){impl_->logic->release_buttons();}
|
||||
void MachineSimulation::apply_tuning(const MachineDocument& d){impl_->apply_tuning(d);}
|
||||
}
|
||||
101
src/mechanics.cpp
Normal file
101
src/mechanics.cpp
Normal file
@@ -0,0 +1,101 @@
|
||||
#include "FluidSim/Mechanics.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace {
|
||||
void require(bool condition,const char* message){if(!condition)throw std::invalid_argument(message);}
|
||||
void drag_parameters(double constant,double viscous,double quadratic){require(std::isfinite(constant)&&constant>=0&&std::isfinite(viscous)&&viscous>=0&&std::isfinite(quadratic)&&quadratic>=0,"Mechanical drag coefficients must be finite and nonnegative");}
|
||||
void drive_parameters(const DriveParameters& p){require(p.mode==DriveMode::Torque||p.mode==DriveMode::Speed,"Unknown drive mode");require(std::isfinite(p.command),"Drive command must be finite");}
|
||||
double sign(double value){return value>0?1.:value<0?-1.:0.;}
|
||||
}
|
||||
struct MechanicalSystem::Impl {
|
||||
struct Shaft {ShaftParameters parameters;ShaftState state;double pending_torque{};std::vector<std::size_t> drives,brakes;};
|
||||
struct Drive {DriveParameters parameters;DriveState state;};
|
||||
struct Brake {BrakeParameters parameters;BrakeState state;double step_start_loss{};};
|
||||
std::vector<Shaft> shafts;std::vector<Drive> drives;std::vector<Brake> brakes;std::set<std::uint32_t> ids;bool started{};
|
||||
Shaft& shaft(std::uint32_t id){for(auto& s:shafts)if(s.state.id==id)return s;throw std::invalid_argument("Unknown mechanical shaft");}
|
||||
const Shaft& shaft(std::uint32_t id)const{for(const auto& s:shafts)if(s.state.id==id)return s;throw std::invalid_argument("Unknown mechanical shaft");}
|
||||
Drive& drive(std::uint32_t id){for(auto& d:drives)if(d.state.id==id)return d;throw std::invalid_argument("Unknown mechanical drive");}
|
||||
Brake& brake(std::uint32_t id){for(auto& b:brakes)if(b.state.id==id)return b;throw std::invalid_argument("Unknown mechanical brake");}
|
||||
void claim(std::uint32_t id){require(!started,"Mechanical topology changes require a fresh simulation");require(id&&ids.insert(id).second,"Duplicate or invalid mechanical part ID");}
|
||||
void validate_drive(const Shaft& s,const DriveParameters& parameters,std::uint32_t replacing=0)const{
|
||||
drive_parameters(parameters);if(!parameters.enabled||parameters.mode!=DriveMode::Speed)return;
|
||||
require(!s.parameters.forward_only||parameters.command>=0,"A forward-only shaft cannot use a negative speed target");
|
||||
for(auto i:s.drives){const auto& other=drives[i];if(other.state.id!=replacing)require(!(other.parameters.enabled&&other.parameters.mode==DriveMode::Speed),"A shaft supports only one active ideal speed drive; reaction sharing would be undefined");}
|
||||
}
|
||||
void losses(Shaft& s,double speed,double duration){
|
||||
const double magnitude=std::abs(speed),direction=sign(speed),distance=magnitude*duration;const auto& p=s.parameters;
|
||||
const double friction=p.friction_constant+p.friction_viscous*magnitude+p.friction_quadratic*magnitude*magnitude;
|
||||
s.state.friction_loss+=friction*distance;s.state.friction_torque-=direction*friction*duration;
|
||||
for(auto index:s.brakes){auto& b=brakes[index];if(!b.parameters.enabled)continue;const auto& c=b.parameters;const double torque=c.constant+c.viscous*magnitude+c.quadratic*magnitude*magnitude;
|
||||
b.state.torque-=direction*torque*duration;b.state.loss+=torque*distance;s.state.brake_loss+=torque*distance;s.state.brake_torque-=direction*torque*duration;
|
||||
}
|
||||
}
|
||||
void static_reaction(Shaft& s,double torque,double constant,double duration){
|
||||
// At rest only the needed fraction of Coulomb capacity reacts; no work.
|
||||
const double fraction=constant>0?std::min(1.,std::abs(torque)/constant):0,direction=sign(torque);
|
||||
s.state.friction_torque-=direction*s.parameters.friction_constant*fraction*duration;
|
||||
for(auto index:s.brakes){auto& b=brakes[index];if(!b.parameters.enabled)continue;const double reaction=-direction*b.parameters.constant*fraction*duration;b.state.torque+=reaction;s.state.brake_torque+=reaction;}
|
||||
if(s.parameters.forward_only&&torque<0) s.state.constraint_torque+=(-torque+direction*constant*fraction)*duration;
|
||||
}
|
||||
void advance(Shaft& s,double dt){
|
||||
auto& state=s.state;const double before=state.speed,energy=state.kinetic_energy;state.previous_angle=state.angle;
|
||||
state.friction_torque=state.brake_torque=state.constraint_torque=0;double torque=s.pending_torque,constant=s.parameters.friction_constant,viscous=s.parameters.friction_viscous,quadratic=s.parameters.friction_quadratic;Drive* speed_drive=nullptr;
|
||||
for(auto index:s.drives){auto& d=drives[index];d.state.torque=d.state.power=0;if(!d.parameters.enabled)continue;if(d.parameters.mode==DriveMode::Speed)speed_drive=&d;else torque+=d.parameters.command;}
|
||||
for(auto index:s.brakes){auto& b=brakes[index];b.state.torque=b.state.power=0;b.step_start_loss=b.state.loss;if(b.parameters.enabled){constant+=b.parameters.constant;viscous+=b.parameters.viscous;quadratic+=b.parameters.quadratic;}}
|
||||
const double old_losses=state.friction_loss+state.brake_loss;
|
||||
double displacement=0;
|
||||
if(speed_drive){
|
||||
state.speed=speed_drive->parameters.command;displacement=state.speed*dt;losses(s,state.speed,dt);
|
||||
}else{
|
||||
double remaining=dt;
|
||||
// A step can decelerate to rest, then accelerate in the other direction.
|
||||
// Each segment uses midpoint work, so its kinetic-energy change equals
|
||||
// source work minus drag work even when a stop splits the interval.
|
||||
for(int segment=0;segment<2&&remaining>0;++segment){
|
||||
const double w0=std::abs(state.speed),direction=w0>0?sign(state.speed):sign(torque);
|
||||
if(w0==0&&(std::abs(torque)<=constant||(s.parameters.forward_only&&torque<=0))){static_reaction(s,torque,constant,remaining);break;}
|
||||
const double source=torque*direction;
|
||||
const double a=quadratic*.25,b=state.inertia/remaining+viscous*.5+quadratic*w0*.5;
|
||||
const double c=quadratic*w0*w0*.25-state.inertia*w0/remaining-source+constant+viscous*w0*.5;
|
||||
double duration=remaining,w1=0;
|
||||
if(c>0&&w0>0){duration=state.inertia*w0/(constant+viscous*w0*.5+quadratic*w0*w0*.25-source);duration=std::clamp(duration,0.,remaining);}
|
||||
else if(c<=0){w1=a>0?-2*c/(b+std::sqrt(b*b-4*a*c)):-c/b;}
|
||||
const double midpoint=direction*(w0+w1)*.5;displacement+=midpoint*duration;losses(s,midpoint,duration);state.speed=direction*w1;remaining-=duration;
|
||||
if(w1!=0)break;
|
||||
}
|
||||
}
|
||||
state.angle+=displacement;state.kinetic_energy=.5*state.inertia*state.speed*state.speed;
|
||||
state.external_work+=s.pending_torque*displacement;
|
||||
for(auto index:s.drives){auto& d=drives[index];if(!d.parameters.enabled||d.parameters.mode!=DriveMode::Torque)continue;const double work=d.parameters.command*displacement;d.state.torque=d.parameters.command;d.state.work+=work;d.state.power=work/dt;state.drive_work+=work;}
|
||||
state.friction_torque/=dt;state.brake_torque/=dt;state.constraint_torque/=dt;
|
||||
state.net_torque=state.inertia*(state.speed-before)/dt;state.source_torque=torque;state.external_torque=s.pending_torque;
|
||||
if(speed_drive){const double work=state.kinetic_energy-energy-torque*displacement+(state.friction_loss+state.brake_loss-old_losses);
|
||||
speed_drive->state.torque=state.net_torque-torque-state.friction_torque-state.brake_torque;speed_drive->state.work+=work;speed_drive->state.power=work/dt;state.drive_work+=work;state.source_torque+=speed_drive->state.torque;}
|
||||
for(auto index:s.brakes){auto& b=brakes[index];b.state.torque/=dt;b.state.power=(b.state.loss-b.step_start_loss)/dt;}
|
||||
s.pending_torque=0;
|
||||
require(std::isfinite(state.angle)&&std::isfinite(state.speed)&&std::isfinite(state.kinetic_energy)&&std::isfinite(state.drive_work)&&std::isfinite(state.external_work)&&std::isfinite(state.friction_loss)&&std::isfinite(state.brake_loss),"Nonfinite mechanical state");
|
||||
}
|
||||
};
|
||||
MechanicalSystem::MechanicalSystem():impl_(std::make_unique<Impl>()){}
|
||||
MechanicalSystem::~MechanicalSystem()=default;
|
||||
void MechanicalSystem::add_shaft(std::uint32_t id,const ShaftParameters& p){
|
||||
require(std::isfinite(p.inertia)&&p.inertia>0&&std::isfinite(p.initial_speed)&&std::isfinite(p.initial_angle),"Invalid shaft inertia or initial state");require(!p.forward_only||p.initial_speed>=0,"A forward-only shaft cannot start in reverse");drag_parameters(p.friction_constant,p.friction_viscous,p.friction_quadratic);impl_->claim(id);
|
||||
Impl::Shaft s;s.parameters=p;s.state.id=id;s.state.inertia=p.inertia;s.state.angle=s.state.previous_angle=p.initial_angle;s.state.speed=p.initial_speed;s.state.initial_energy=s.state.kinetic_energy=.5*p.inertia*p.initial_speed*p.initial_speed;impl_->shafts.push_back(std::move(s));
|
||||
}
|
||||
void MechanicalSystem::add_flywheel(std::uint32_t id,std::uint32_t shaft,double inertia){require(std::isfinite(inertia)&&inertia>0,"Flywheel inertia must be positive and finite");auto& s=impl_->shaft(shaft);impl_->claim(id);s.state.inertia+=inertia;s.state.initial_energy=s.state.kinetic_energy=.5*s.state.inertia*s.state.speed*s.state.speed;}
|
||||
void MechanicalSystem::add_drive(std::uint32_t id,std::uint32_t shaft,const DriveParameters& p){auto& s=impl_->shaft(shaft);impl_->validate_drive(s,p);impl_->claim(id);s.drives.push_back(impl_->drives.size());Impl::Drive d;d.parameters=p;d.state.id=id;d.state.shaft=shaft;impl_->drives.push_back(d);}
|
||||
void MechanicalSystem::add_brake(std::uint32_t id,std::uint32_t shaft,const BrakeParameters& p){drag_parameters(p.constant,p.viscous,p.quadratic);auto& s=impl_->shaft(shaft);impl_->claim(id);s.brakes.push_back(impl_->brakes.size());Impl::Brake b;b.parameters=p;b.state.id=id;b.state.shaft=shaft;impl_->brakes.push_back(b);}
|
||||
void MechanicalSystem::set_drive(std::uint32_t id,const DriveParameters& p){auto& d=impl_->drive(id);impl_->validate_drive(impl_->shaft(d.state.shaft),p,id);d.parameters=p;}
|
||||
void MechanicalSystem::set_brake(std::uint32_t id,const BrakeParameters& p){drag_parameters(p.constant,p.viscous,p.quadratic);impl_->brake(id).parameters=p;}
|
||||
void MechanicalSystem::set_friction(std::uint32_t id,double constant,double viscous,double quadratic){drag_parameters(constant,viscous,quadratic);auto& p=impl_->shaft(id).parameters;p.friction_constant=constant;p.friction_viscous=viscous;p.friction_quadratic=quadratic;}
|
||||
void MechanicalSystem::add_torque(std::uint32_t shaft,double torque){require(std::isfinite(torque),"Shaft torque must be finite");auto& s=impl_->shaft(shaft);require(std::isfinite(s.pending_torque+torque),"Accumulated shaft torque overflow");s.pending_torque+=torque;}
|
||||
void MechanicalSystem::step(double dt){require(std::isfinite(dt)&&dt>0,"Mechanical timestep must be positive and finite");impl_->started=true;for(auto& shaft:impl_->shafts)impl_->advance(shaft,dt);}
|
||||
const ShaftState& MechanicalSystem::shaft(std::uint32_t id)const{return impl_->shaft(id).state;}
|
||||
const DriveState& MechanicalSystem::drive(std::uint32_t id)const{return impl_->drive(id).state;}
|
||||
const BrakeState& MechanicalSystem::brake(std::uint32_t id)const{return impl_->brake(id).state;}
|
||||
}
|
||||
@@ -1,83 +1,187 @@
|
||||
#include "FluidSim/Desktop/ScenarioMenu.hpp"
|
||||
#include "FluidSim/Desktop/MachineRenderer3D.hpp"
|
||||
#include "FluidSim/Desktop/WindowPresentation.hpp"
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
namespace fluidsim::port::desktop {
|
||||
std::optional<std::size_t> choose_scenario(sf::RenderWindow& window,const sf::Font* font,const std::vector<ScenarioExample>& examples) {
|
||||
if(examples.empty())return std::nullopt;
|
||||
int selected=0,first=0;
|
||||
std::vector<std::string> categories;
|
||||
for(const auto& e:examples) if(std::find(categories.begin(),categories.end(),e.category)==categories.end())categories.push_back(e.category);
|
||||
std::string category;
|
||||
struct Item { std::string title,description;std::size_t value; };
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <sstream>
|
||||
|
||||
auto label=[&](const std::string& str,float x,float y,unsigned size,sf::Color color=sf::Color::White) {
|
||||
if(!font)return;
|
||||
sf::Text text(*font,str,size);text.setPosition({x,y});text.setFillColor(color);window.draw(text);
|
||||
};
|
||||
while(window.isOpen()) {
|
||||
const auto size=window.getSize();const float w=float(size.x),h=float(size.y);
|
||||
std::vector<Item> items;
|
||||
if(category.empty()) for(std::size_t i=0;i<categories.size();++i) {
|
||||
const auto count=std::count_if(examples.begin(),examples.end(),[&](const auto& e){return e.category==categories[i];});
|
||||
items.push_back({categories[i],std::to_string(count)+" variants / click to browse",i});
|
||||
} else for(std::size_t i=0;i<examples.size();++i) if(examples[i].category==category)items.push_back({examples[i].title,examples[i].description,i});
|
||||
const int count=int(items.size()), rows=std::max(1,int((h-210)/86));
|
||||
first=std::clamp(first,0,std::max(0,count-rows));
|
||||
const sf::FloatRect start{{24,h-85},{w-48,46}};
|
||||
bool navigating=false;
|
||||
auto activate=[&]()->std::optional<std::size_t> {
|
||||
if(category.empty()) {category=categories[items[selected].value];selected=0;first=0;navigating=true;return std::nullopt;}
|
||||
return items[selected].value;
|
||||
};
|
||||
auto back=[&] {category.clear();selected=0;first=0;navigating=true;};
|
||||
auto reveal=[&] {if(selected<first)first=selected;if(selected>=first+rows)first=selected-rows+1;};
|
||||
while(auto event=window.pollEvent()) {
|
||||
if(event->is<sf::Event::Closed>())return std::nullopt;
|
||||
if(auto key=event->getIf<sf::Event::KeyPressed>()) {
|
||||
switch(key->code) {
|
||||
case sf::Keyboard::Key::Escape:if(category.empty())return std::nullopt;back();break;
|
||||
case sf::Keyboard::Key::Backspace:case sf::Keyboard::Key::Left:if(!category.empty())back();break;
|
||||
case sf::Keyboard::Key::Enter:if(auto choice=activate())return choice;break;
|
||||
case sf::Keyboard::Key::Up:selected=std::max(0,selected-1);reveal();break;
|
||||
case sf::Keyboard::Key::Down:case sf::Keyboard::Key::Tab:selected=std::min(count-1,selected+1);reveal();break;
|
||||
case sf::Keyboard::Key::PageUp:selected=std::max(0,selected-rows);reveal();break;
|
||||
case sf::Keyboard::Key::PageDown:selected=std::min(count-1,selected+rows);reveal();break;
|
||||
case sf::Keyboard::Key::Home:selected=0;reveal();break;
|
||||
case sf::Keyboard::Key::End:selected=count-1;reveal();break;
|
||||
default:break;
|
||||
}
|
||||
}
|
||||
if(auto wheel=event->getIf<sf::Event::MouseWheelScrolled>()) {
|
||||
first=std::clamp(first+(wheel->delta<0?1:-1),0,std::max(0,count-rows));
|
||||
selected=std::clamp(selected,first,std::min(count-1,first+rows-1));
|
||||
}
|
||||
if(auto click=event->getIf<sf::Event::MouseButtonReleased>();click && click->button==sf::Mouse::Button::Left) {
|
||||
sf::Vector2f point{float(click->position.x),float(click->position.y)};
|
||||
if(!category.empty() && sf::FloatRect({24,54},{150,36}).contains(point))back();
|
||||
if(start.contains(point)) {if(auto choice=activate())return choice;}
|
||||
for(int row=0;row<rows && first+row<count;++row)
|
||||
if(!navigating && sf::FloatRect({24,105.f+row*86},{w-58,76}).contains(point)) {selected=first+row;if(auto choice=activate())return choice;break;}
|
||||
}
|
||||
if(navigating)break;
|
||||
}
|
||||
if(navigating)continue;
|
||||
window.setView(sf::View(sf::FloatRect({0,0},{w,h})));window.clear({8,8,8});
|
||||
label("FLUIDSIM / EXAMPLES",24,20,24);label(category.empty()?"Choose an engine category":"< Categories "+category,24,61,15,{170,170,170});
|
||||
for(int row=0;row<rows && first+row<count;++row) {
|
||||
const int index=first+row;const float y=105.f+row*86;
|
||||
sf::RectangleShape card({w-58,76});card.setPosition({24,y});
|
||||
card.setFillColor(index==selected?sf::Color(48,48,48):sf::Color(23,23,23));
|
||||
card.setOutlineThickness(1);card.setOutlineColor(index==selected?sf::Color::White:sf::Color(60,60,60));window.draw(card);
|
||||
label(items[index].title,38,y+10,17);label(items[index].description,38,y+44,12,{170,170,170});
|
||||
}
|
||||
if(count>rows) {
|
||||
const float track=rows*86.f, thumb=track*std::min(1.f,float(rows)/count);
|
||||
sf::RectangleShape bar({4,thumb});bar.setPosition({w-21,105+(track-thumb)*first/std::max(1,count-rows)});bar.setFillColor({170,170,170});window.draw(bar);
|
||||
}
|
||||
sf::RectangleShape button(start.size);button.setPosition(start.position);button.setFillColor({55,55,55});window.draw(button);
|
||||
label(category.empty()?"Browse variants [Enter]":"Start selected [Enter]",38,h-76,16);
|
||||
label("Wheel / Up / Down / Page keys | "+std::to_string(selected+1)+" / "+std::to_string(count),24,h-28,12,{170,170,170});
|
||||
window.display();
|
||||
namespace fluidsim::port::desktop {
|
||||
namespace {
|
||||
const sf::Color ink{232,238,246},muted{152,168,190},accent{116,170,235},edge{53,67,86},surface{23,31,43};
|
||||
std::string lower(std::string text){for(auto& c:text)c=char(std::tolower(static_cast<unsigned char>(c)));return text;}
|
||||
std::string fit(const sf::Font* font,std::string text,float width,unsigned size){
|
||||
if(!font)return {};
|
||||
sf::Text measured(*font,text,size);if(measured.getLocalBounds().size.x<=width)return text;
|
||||
while(!text.empty()){text.pop_back();measured.setString(text+"...");if(measured.getLocalBounds().size.x<=width)return text+"...";}return {};
|
||||
}
|
||||
void text(sf::RenderWindow& w,const sf::Font* font,const std::string& value,float x,float y,float width,unsigned size=14,sf::Color color=ink){
|
||||
if(!font)return;
|
||||
sf::Text label(*font,fit(font,value,width,size),size);label.setPosition({x,y});label.setFillColor(color);w.draw(label);
|
||||
}
|
||||
void wrap(sf::RenderWindow& w,const sf::Font* font,const std::string& value,float x,float y,float width,unsigned size,int lines,sf::Color color=muted){
|
||||
if(!font)return;
|
||||
std::istringstream words(value);std::string word,line;int row=0;sf::Text measured(*font,"",size);
|
||||
while(words>>word){const auto candidate=line.empty()?word:line+" "+word;measured.setString(candidate);
|
||||
if(!line.empty()&&measured.getLocalBounds().size.x>width){text(w,font,line,x,y+row*(size+7),width,size,color);if(++row>=lines)return;line=word;}else line=candidate;
|
||||
}if(row<lines)text(w,font,line,x,y+row*(size+7),width,size,color);
|
||||
}
|
||||
void box(sf::RenderWindow& w,sf::FloatRect r,sf::Color fill=surface,sf::Color outline=edge){sf::RectangleShape shape(r.size);shape.setPosition(r.position);shape.setFillColor(fill);shape.setOutlineThickness(1);shape.setOutlineColor(outline);w.draw(shape);}
|
||||
struct MenuLayout {
|
||||
float width,height,list_x,list_width;int rows;
|
||||
sf::FloatRect back,search,detail;
|
||||
static MenuLayout calculate(sf::Vector2u size,bool scenarios){
|
||||
MenuLayout l;l.width=float(size.x);l.height=float(size.y);l.list_x=scenarios?252.f:32.f;
|
||||
const float detail_width=std::clamp(l.width*.36f,380.f,600.f);
|
||||
l.detail={{l.width-detail_width-32,154},{detail_width,l.height-210}};
|
||||
l.list_width=l.detail.position.x-l.list_x-24;l.rows=std::max(1,int((l.height-216)/78));
|
||||
l.back={{32,28},{112,40}};l.search={{l.list_x,104},{l.list_width,32}};return l;
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
}
|
||||
struct ScenarioMenu::Impl {
|
||||
struct Item{std::string title,description;std::optional<std::size_t> example;std::filesystem::path machine;};
|
||||
struct Action{sf::FloatRect rect;std::string title,subtitle;std::function<void()> run;bool selected{},enabled{true},primary{};int kind{};};
|
||||
std::vector<ScenarioExample> examples;std::vector<std::filesystem::path> machines;std::vector<std::string> categories{"All engines"};
|
||||
std::vector<Item> items,saved_items;MenuScreen page{MenuScreen::Home};int selected{},first{},category{},category_first{},home_selection{};
|
||||
bool quit{},search_focus{};std::string query;sf::Vector2f mouse{-1,-1};std::optional<ScenarioMenuChoice> choice;
|
||||
MachineRenderer3D renderer;std::optional<MachineDocument> preview;OrbitCamera camera;std::string preview_key,preview_note;
|
||||
Impl(std::vector<ScenarioExample> e,std::vector<std::filesystem::path> m):examples(std::move(e)),machines(std::move(m)){
|
||||
for(const auto& item:examples)if(std::find(categories.begin(),categories.end(),item.category)==categories.end())categories.push_back(item.category);
|
||||
for(const auto& file:machines){std::string title=file.stem().stem().string();try{title=load_machine(file).name;}catch(const std::exception&){}saved_items.push_back({title,file.filename().string()+" / edit or play",{},file});}
|
||||
}
|
||||
void refresh(){
|
||||
items.clear();const auto search=lower(query);
|
||||
const auto add=[&](Item item){if(search.empty()||lower(item.title+" "+item.description).find(search)!=std::string::npos)items.push_back(std::move(item));};
|
||||
if(page==MenuScreen::Workshop){add({"New machine","Start an empty workshop and build your own machine",{}, {}});for(const auto& item:saved_items)add(item);}
|
||||
if(page==MenuScreen::Scenarios)for(std::size_t i=0;i<examples.size();++i)if(category==0||examples[i].category==categories[category])add({examples[i].title,examples[i].description,i,{}});
|
||||
selected=std::clamp(selected,0,std::max(0,int(items.size())-1));
|
||||
}
|
||||
void navigate(MenuScreen screen){page=screen;selected=first=category=category_first=0;query.clear();search_focus=false;preview_key.clear();refresh();}
|
||||
void reveal(int rows){if(selected<first)first=selected;if(selected>=first+rows)first=selected-rows+1;}
|
||||
void activate(bool play=false){
|
||||
if(page==MenuScreen::Home){if(home_selection==0)navigate(MenuScreen::Workshop);else if(home_selection==1)navigate(MenuScreen::Scenarios);else quit=true;return;}
|
||||
if(items.empty())return;
|
||||
const auto& item=items[selected];choice=ScenarioMenuChoice{item.example,item.machine,play&&!item.machine.empty()};
|
||||
}
|
||||
std::vector<Action> actions(sf::Vector2u size){
|
||||
const auto l=MenuLayout::calculate(size,page==MenuScreen::Scenarios);std::vector<Action> result;
|
||||
const auto add=[&](sf::FloatRect r,std::string title,std::function<void()> run,bool active=false,bool enabled=true,bool primary=false,std::string subtitle={},int kind=0){result.push_back({r,std::move(title),std::move(subtitle),std::move(run),active,enabled,primary,kind});};
|
||||
if(page==MenuScreen::Home){
|
||||
const float top=std::max(254.f,l.height*.36f);
|
||||
add({{64,top},{416,92}},"Machine Workshop",[this]{navigate(MenuScreen::Workshop);},home_selection==0,true,false,"Build, connect and tune your own machines",2);
|
||||
add({{64,top+108},{416,92}},"Engine Scenarios",[this]{navigate(MenuScreen::Scenarios);},home_selection==1,true,false,"Explore the engine collection",2);
|
||||
add({{64,top+224},{196,44}},"Quit",[this]{quit=true;},home_selection==2);
|
||||
return result;
|
||||
}
|
||||
add(l.back,"< Back",[this]{navigate(MenuScreen::Home);});
|
||||
first=std::clamp(first,0,std::max(0,int(items.size())-l.rows));
|
||||
for(int row=0;row<l.rows&&first+row<int(items.size());++row){const int i=first+row;add({{l.list_x,154+row*78.f},{l.list_width,68}},items[i].title,[this,i]{selected=i;preview_key.clear();},i==selected,true,false,items[i].description,1);}
|
||||
if(page==MenuScreen::Scenarios){const int rows=std::max(1,int((l.height-230)/44));category_first=std::clamp(category_first,0,std::max(0,int(categories.size())-rows));
|
||||
for(int row=0;row<rows&&category_first+row<int(categories.size());++row){const int i=category_first+row;add({{32,154+row*44.f},{196,34}},categories[i],[this,i]{category=i;selected=first=0;refresh();preview_key.clear();},category==i);}
|
||||
}
|
||||
const bool any=!items.empty();const bool saved=any&&!items[selected].machine.empty();const auto d=l.detail;
|
||||
add({{d.position.x+20,d.position.y+d.size.y-52},{d.size.x-40,34}},page==MenuScreen::Scenarios?"Start engine [Enter]":saved?"Edit machine [Enter]":"Create machine [Enter]",[this]{activate();},false,any,true);
|
||||
if(page==MenuScreen::Workshop&&saved)add({{d.position.x+20,d.position.y+d.size.y-96},{d.size.x-40,34}},"Play machine [Space]",[this]{activate(true);});
|
||||
return result;
|
||||
}
|
||||
void update_preview(){
|
||||
const Item* item=page!=MenuScreen::Home&&!items.empty()?&items[selected]:nullptr;
|
||||
std::string key=page==MenuScreen::Home?"home":item?(item->example?"engine:"+std::to_string(*item->example):item->machine.empty()?"new":"machine:"+item->machine.string()):"none";
|
||||
if(key==preview_key)return;
|
||||
preview_key=key;preview.reset();preview_note.clear();
|
||||
try{
|
||||
if(item&&item->example){const auto& engine=examples[*item->example];if(engine.kind==ScenarioKind::TwoStroke){preview_note="TWO-STROKE / ENGINE SCENARIO";return;}preview=machine_from_engine(engine);}
|
||||
else if(item&&!item->machine.empty())preview=load_machine(item->machine);
|
||||
else if(item)preview=MachineDocument{};
|
||||
else if(page==MenuScreen::Home){auto found=std::find_if(examples.begin(),examples.end(),[](const auto& e){return e.id=="flat6";});if(found!=examples.end())preview=machine_from_engine(*found);else preview=machine_engine_example();}
|
||||
if(!preview)return;
|
||||
if(preview->parts.empty()){camera.target={0,.22f,0};camera.distance=1.8f;camera.pitch=24;camera.yaw=-32;return;}
|
||||
Vec3 lo{1e6f,1e6f,1e6f},hi{-1e6f,-1e6f,-1e6f};
|
||||
const auto include=[&](Vec3 p){lo={std::min(lo.x,p.x),std::min(lo.y,p.y),std::min(lo.z,p.z)};hi={std::max(hi.x,p.x),std::max(hi.y,p.y),std::max(hi.z,p.z)};};
|
||||
for(const auto& part:preview->parts){if(part.kind==MachinePartKind::Pipe)for(auto p:machine_pipe_path(part))include(p);else{const auto half=machine_math::multiply(machine_part_size(part),.5f);include(machine_math::subtract(part.position,half));include(machine_math::add(part.position,half));}}
|
||||
camera.target=machine_math::multiply(machine_math::add(lo,hi),.5f);camera.distance=std::clamp(machine_math::length(machine_math::subtract(hi,lo))*1.3f+.2f,.8f,100.f);camera.pitch=24;camera.yaw=-32;
|
||||
}catch(const std::exception&){preview.reset();preview_note="No 3D preview available";}
|
||||
}
|
||||
};
|
||||
ScenarioMenu::ScenarioMenu(std::vector<ScenarioExample> e,std::vector<std::filesystem::path> m):impl_(std::make_unique<Impl>(std::move(e),std::move(m))){}
|
||||
ScenarioMenu::~ScenarioMenu()=default;
|
||||
bool ScenarioMenu::finished()const{return impl_->quit;}
|
||||
MenuScreen ScenarioMenu::screen()const{return impl_->page;}
|
||||
std::optional<ScenarioMenuChoice> ScenarioMenu::take_choice(){auto result=impl_->choice;impl_->choice.reset();return result;}
|
||||
bool ScenarioMenu::handle_event(const sf::Event& event,sf::Vector2u size){
|
||||
auto& m=*impl_;const auto layout=MenuLayout::calculate(size,m.page==MenuScreen::Scenarios);
|
||||
if(event.is<sf::Event::Closed>()){m.quit=true;return true;}
|
||||
if(auto move=event.getIf<sf::Event::MouseMoved>()){m.mouse=sf::Vector2f(move->position);return true;}
|
||||
if(auto press=event.getIf<sf::Event::MouseButtonPressed>();press&&press->button==sf::Mouse::Button::Left){m.search_focus=m.page!=MenuScreen::Home&&layout.search.contains(sf::Vector2f(press->position));return true;}
|
||||
if(auto typed=event.getIf<sf::Event::TextEntered>();typed&&m.search_focus){if(typed->unicode>=32&&typed->unicode<127&&m.query.size()<80){m.query+=char(typed->unicode);m.selected=m.first=0;m.refresh();}return true;}
|
||||
if(auto key=event.getIf<sf::Event::KeyPressed>()){
|
||||
using K=sf::Keyboard::Key;const auto k=key->code;
|
||||
if(m.search_focus){if(k==K::Escape||k==K::Enter)m.search_focus=false;else if(k==K::Backspace&&!m.query.empty()){m.query.pop_back();m.selected=m.first=0;m.refresh();}return true;}
|
||||
if(key->control&&k==K::F&&m.page!=MenuScreen::Home){m.search_focus=true;return true;}
|
||||
if(k==K::Escape||k==K::Backspace){if(m.page==MenuScreen::Home){if(k==K::Escape)m.quit=true;}else m.navigate(MenuScreen::Home);return true;}
|
||||
if(k==K::B){m.choice=ScenarioMenuChoice{};return true;}
|
||||
if(k==K::Enter||k==K::Space){m.activate(k==K::Space);return true;}
|
||||
int& selected=m.page==MenuScreen::Home?m.home_selection:m.selected;const int count=m.page==MenuScreen::Home?3:int(m.items.size());
|
||||
if(k==K::Up)selected=std::max(0,selected-1);else if(k==K::Down||k==K::Tab)selected=std::min(std::max(0,count-1),selected+1);else if(k==K::Home)selected=0;else if(k==K::End)selected=std::max(0,count-1);else if(k==K::PageDown)selected=std::min(std::max(0,count-1),selected+layout.rows);else if(k==K::PageUp)selected=std::max(0,selected-layout.rows);else return false;
|
||||
m.reveal(layout.rows);return true;
|
||||
}
|
||||
if(auto wheel=event.getIf<sf::Event::MouseWheelScrolled>();wheel&&m.page!=MenuScreen::Home){
|
||||
if(m.page==MenuScreen::Scenarios&&wheel->position.x<232){const int rows=std::max(1,int((layout.height-230)/44));m.category_first=std::clamp(m.category_first+(wheel->delta<0?1:-1),0,std::max(0,int(m.categories.size())-rows));}
|
||||
else{m.first=std::clamp(m.first+(wheel->delta<0?1:-1),0,std::max(0,int(m.items.size())-layout.rows));if(!m.items.empty())m.selected=std::clamp(m.selected,m.first,std::min(int(m.items.size())-1,m.first+layout.rows-1));}return true;
|
||||
}
|
||||
if(auto click=event.getIf<sf::Event::MouseButtonReleased>();click&&click->button==sf::Mouse::Button::Left){m.mouse=sf::Vector2f(click->position);auto actions=m.actions(size);for(auto& action:actions)if(action.rect.contains(m.mouse)){if(action.enabled)action.run();return true;}return true;}
|
||||
return false;
|
||||
}
|
||||
void ScenarioMenu::draw(sf::RenderWindow& w,const sf::Font* font){
|
||||
auto& m=*impl_;const auto size=w.getSize();const auto l=MenuLayout::calculate(size,m.page==MenuScreen::Scenarios);const auto full=sf::View(sf::FloatRect({0,0},sf::Vector2f(size)));
|
||||
w.setView(full);w.clear({11,16,24});
|
||||
m.update_preview();const bool home=m.page==MenuScreen::Home;
|
||||
const sf::FloatRect scene=home?sf::FloatRect{{528,96},{l.width-560,l.height-200}}:sf::FloatRect{l.detail.position,{l.detail.size.x,l.detail.size.y*.43f}};
|
||||
if(!home)box(w,l.detail);
|
||||
if(m.preview)m.renderer.draw(w,*m.preview,nullptr,scene,m.camera,0);else{
|
||||
box(w,scene,{15,22,32});for(int i=0;i<6;++i){sf::CircleShape ring(30.f+i*13);ring.setFillColor(sf::Color::Transparent);ring.setOutlineColor({38,55,76});ring.setOutlineThickness(1);ring.setPosition({scene.position.x+scene.size.x*.5f-ring.getRadius(),scene.position.y+scene.size.y*.5f-ring.getRadius()});w.draw(ring);}
|
||||
text(w,font,m.preview_note,scene.position.x+20,scene.position.y+scene.size.y*.5f-8,scene.size.x-40,12,accent);
|
||||
}
|
||||
w.setView(full);
|
||||
if(home){
|
||||
text(w,font,"FLUIDSIM",64,96,440,54);text(w,font,"THE MACHINE SANDBOX",68,170,412,14,accent);
|
||||
wrap(w,font,"Explore engine sound. Build machines. Put your ideas in motion.",68,204,390,14,2);
|
||||
text(w,font,"BUILD. CONNECT. SIMULATE.",552,l.height-92,l.width-600,18,ink);
|
||||
}else{
|
||||
text(w,font,m.page==MenuScreen::Workshop?"MACHINE WORKSHOP":"ENGINE SCENARIOS",174,31,l.width-220,26);
|
||||
text(w,font,m.page==MenuScreen::Workshop?"Create a machine, or continue one of your saved designs.":"Choose a category, select an engine, then start your session.",32,80,l.width-64,13,muted);
|
||||
box(w,l.search,{14,21,31},m.search_focus?accent:edge);text(w,font,m.query.empty()&&!m.search_focus?"Search... [Ctrl+F]":m.query+(m.search_focus?"|":""),l.search.position.x+10,111,l.search.size.x-20,13,muted);
|
||||
if(m.page==MenuScreen::Scenarios){text(w,font,"COLLECTION",32,112,196,12,accent);if(int(m.categories.size())>std::max(1,int((l.height-230)/44)))text(w,font,"Scroll for more categories",32,l.height-64,196,11,muted);}
|
||||
const auto d=l.detail;const float info_y=scene.position.y+scene.size.y+18;
|
||||
if(!m.items.empty()){
|
||||
const auto& item=m.items[m.selected];wrap(w,font,item.title,d.position.x+20,info_y,d.size.x-40,18,2,ink);
|
||||
const auto subtitle=item.example?m.examples[*item.example].category:item.machine.empty()?"NEW DESIGN":"SAVED MACHINE";
|
||||
text(w,font,subtitle,d.position.x+20,info_y+58,d.size.x-40,11,accent);
|
||||
wrap(w,font,item.description,d.position.x+20,info_y+86,d.size.x-40,13,3);
|
||||
}else{
|
||||
text(w,font,"No matches",l.list_x,174,l.list_width,18);wrap(w,font,"Clear your search or choose another category.",l.list_x,210,l.list_width,13,2);
|
||||
}
|
||||
if(int(m.items.size())>l.rows){const float height=l.rows*78.f;box(w,{{l.detail.position.x-14,154},{3,height}},{32,43,58});box(w,{{l.detail.position.x-14,154+height*m.first/m.items.size()},{3,height*l.rows/m.items.size()}},accent);}
|
||||
}
|
||||
std::string hover_text;
|
||||
for(const auto& action:m.actions(size)){
|
||||
const bool hover=action.rect.contains(m.mouse);box(w,action.rect,action.primary?sf::Color(42,77,115):action.selected||hover?sf::Color(32,48,68):surface,action.selected||hover||action.primary?accent:edge);
|
||||
const float x=action.rect.position.x+16,y=action.rect.position.y,width=action.rect.size.x-32;
|
||||
text(w,font,action.title,x,y+(action.kind==2?14:action.kind==1?10:9),width,action.kind==2?21:action.kind==1?15:14,action.enabled?ink:sf::Color(91,107,128));
|
||||
if(!action.subtitle.empty())text(w,font,action.subtitle,x,y+(action.kind==2?54:42),width,12,muted);
|
||||
if(hover&&(fit(font,action.title,width,action.kind==2?21:action.kind==1?15:14)!=action.title||(!action.subtitle.empty()&&fit(font,action.subtitle,width,12)!=action.subtitle)))hover_text=action.title+(action.subtitle.empty()?"":" / "+action.subtitle);
|
||||
}
|
||||
text(w,font,home?"A sandbox for engines, compressed air and mechanical systems.":"Up / Down: select Enter: open Escape: main menu B: new machine",32,l.height-30,l.width-64,12,muted);
|
||||
if(!hover_text.empty()){const float width=std::min(520.f,l.width-48),x=std::clamp(m.mouse.x+12,24.f,l.width-width-24),y=std::clamp(m.mouse.y+32,24.f,l.height-112);box(w,{{x,y},{width,70}},{13,20,30},accent);wrap(w,font,hover_text,x+12,y+9,width-24,12,3,ink);}
|
||||
}
|
||||
std::optional<ScenarioMenuChoice> choose_scenario(sf::RenderWindow& window,const sf::Font* font,const std::vector<ScenarioExample>& examples){
|
||||
maximize_window(window);ScenarioMenu menu(examples,machine_files());
|
||||
while(window.isOpen()&&!menu.finished()){
|
||||
while(auto event=window.pollEvent()){menu.handle_event(*event,window.getSize());if(auto choice=menu.take_choice())return choice;if(menu.finished())return {};}
|
||||
menu.draw(window,font);window.display();
|
||||
}return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ bool SimulationRingBuffer::wait_for_space(std::size_t target_fill, std::stop_tok
|
||||
void SimulationRingBuffer::reset_consumer() {
|
||||
consumer_initialized_ = false;
|
||||
}
|
||||
void SimulationRingBuffer::discard(){read_head_.store(write_head_.load(std::memory_order_acquire),std::memory_order_release);consumer_initialized_=false;}
|
||||
|
||||
std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
|
||||
std::span<float> dst_right,
|
||||
|
||||
@@ -26,6 +26,7 @@ void Solver::step(float dt,bool profile_sample) {
|
||||
if (!(sub_dt > 0) || ++count > 1000000)
|
||||
throw std::runtime_error("solver timestep failed to progress");
|
||||
++substep_count_;
|
||||
for (Component *component : components_) component->prepare_step(sub_dt);
|
||||
{ ProfileScope measured(profiler_,profile_sample,ProfileStage::Boundary);boundaries_.resolve(sub_dt); }
|
||||
{ ProfileScope measured(profiler_,profile_sample,ProfileStage::Pipe);
|
||||
for (auto &pipe : pipes_) pipe.advance(sub_dt); }
|
||||
|
||||
30
src/speed_density_example.cpp
Normal file
30
src/speed_density_example.cpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <vector>
|
||||
|
||||
namespace fluidsim::port {
|
||||
MachineDocument machine_speed_density_example(){
|
||||
auto doc=machine_sensor_engine_example(true);doc.name="Sensor speed-density EFI bench";
|
||||
std::uint32_t ecu=0,host=0;
|
||||
for(auto& part:doc.parts){
|
||||
if(part.kind==MachinePartKind::Ecu){ecu=part.id;part.name="Speed-density ECU";part.sensor_ecu.strategy=SensorEcuConfig::Strategy::SpeedDensity;}
|
||||
if(part.injector)host=part.id;
|
||||
}
|
||||
// This is a declared calibration set when constructing the example, not
|
||||
// runtime access to measured/trapped cylinder charge.
|
||||
for(const auto& part:doc.parts)if(part.kind==MachinePartKind::Cylinder)doc.find(ecu)->sensor_ecu.displacement=pi*.25f*part.cylinder.bore*part.cylinder.bore*part.cylinder.stroke;
|
||||
doc.find(host)->injector->ecu_label=doc.find(ecu)->name;
|
||||
std::vector<std::uint32_t> removed;
|
||||
for(const auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Sensor&&node.sensor_kind==MachineSensorKind::ThrottlePosition)removed.push_back(node.id);
|
||||
for(const auto id:removed)doc.logic.remove(id);
|
||||
removed.clear();for(const auto& sensor:doc.sensors)if(sensor.kind==MachineSensorKind::ThrottlePosition)removed.push_back(sensor.id);
|
||||
for(const auto id:removed)doc.remove_sensor(id);
|
||||
std::uint32_t connector=0;
|
||||
for(auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Ecu){connector=node.id;node.ecu_strategy=SensorEcuConfig::Strategy::SpeedDensity;node.binding_label=doc.find(ecu)->name;node.position={340,80};}
|
||||
const auto map=doc.add_sensor(MachineSensorKind::Pressure,host).id,iat=doc.add_sensor(MachineSensorKind::GasTemperature,host).id;
|
||||
doc.find_sensor(map)->name="Intake MAP / absolute";doc.find_sensor(iat)->name="Intake gas temperature / IAT";
|
||||
const auto pressure=doc.logic.add(LogicNodeKind::Sensor,{0,240}).id;doc.logic.find(pressure)->sensor=map;doc.logic.find(pressure)->sensor_kind=MachineSensorKind::Pressure;doc.logic.find(pressure)->name="Measured manifold pressure";
|
||||
const auto temperature=doc.logic.add(LogicNodeKind::Sensor,{0,460}).id;doc.logic.find(temperature)->sensor=iat;doc.logic.find(temperature)->sensor_kind=MachineSensorKind::GasTemperature;doc.logic.find(temperature)->name="Measured intake temperature";
|
||||
doc.logic.connect({pressure,"value"},{connector,"map"});doc.logic.connect({temperature,"value"},{connector,"iat"});
|
||||
validate_machine(doc);return doc;
|
||||
}
|
||||
}
|
||||
28
src/window_presentation.cpp
Normal file
28
src/window_presentation.cpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#include "FluidSim/Desktop/WindowPresentation.hpp"
|
||||
#include <SFML/Window/VideoMode.hpp>
|
||||
#ifdef _WIN32
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace fluidsim::port::desktop {
|
||||
void maximize_window(sf::RenderWindow& window){
|
||||
if(!window.isOpen())return;
|
||||
#ifdef _WIN32
|
||||
ShowWindow(window.getNativeHandle(),SW_MAXIMIZE);
|
||||
#else
|
||||
// SFML has no portable maximize operation. Keep other desktop builds usable.
|
||||
window.setPosition({0,0});window.setSize(sf::VideoMode::getDesktopMode().size);
|
||||
#endif
|
||||
}
|
||||
bool window_is_maximized(const sf::RenderWindow& window){
|
||||
if(!window.isOpen())return false;
|
||||
#ifdef _WIN32
|
||||
return IsZoomed(window.getNativeHandle())!=FALSE;
|
||||
#else
|
||||
return window.getSize()==sf::VideoMode::getDesktopMode().size;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
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;}
|
||||
11
tools/MachineBench.hpp
Normal file
11
tools/MachineBench.hpp
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <stdexcept>
|
||||
namespace fluidsim::port::bench {
|
||||
inline void set_rpm(MachineDocument& design,float rpm){
|
||||
const auto shaft=machine_primary_shaft(design);if(!shaft)throw std::invalid_argument("Benchmark needs a primary shaft");
|
||||
const auto position=design.find(shaft)->position;design.find(shaft)->shaft.initial_speed=rpm*2*pi/60;
|
||||
for(auto& part:design.parts)if(part.kind==MachinePartKind::DriveMotor&&machine_shaft_for(design,part.id)==shaft){part.drive={rpm*2*pi/60,0,true,true};return;}
|
||||
auto& drive=design.add(MachinePartKind::DriveMotor,machine_math::add(position,{-.3f,0,0}));const auto id=drive.id;drive.name="Benchmark speed drive";drive.drive.target_speed=rpm*2*pi/60;design.connect({shaft,0},{id,0});
|
||||
}
|
||||
}
|
||||
32
tools/machine_benchmark.cpp
Normal file
32
tools/machine_benchmark.cpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#include "MachineBench.hpp"
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/Scenario.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
template<class Step> double measure(Step step,int frames){
|
||||
const auto begin=std::chrono::steady_clock::now();
|
||||
for(int i=0;i<frames;++i){const auto value=step();if(!std::isfinite(value.left)||!std::isfinite(value.right))throw std::runtime_error("Nonfinite benchmark audio");}
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count();
|
||||
}
|
||||
int main(int argc,char** argv)try{
|
||||
const int frames=argc>1?std::stoi(argv[1]):32768;const float rpm=argc>2?std::stof(argv[2]):3000,pedal=argc>3?std::stof(argv[3]):.35f;
|
||||
if(frames<1||!std::isfinite(rpm)||rpm<0||rpm>50000||!std::isfinite(pedal)||pedal<0||pedal>1)throw std::invalid_argument("Usage: fluidsim_machine_benchmark [frames>0] [rpm 0..50000] [pedal 0..1]");
|
||||
std::cout<<"scenario,runtime,frames,rpm,pedal,cells,min_cell_mm,substeps_per_frame,us_per_frame,wall_per_sim,positivity_repairs\n";
|
||||
for(const char* id:{"single-450","twin","flat6","v8-h"}){
|
||||
const auto& preset=scenario_example(id);FourStrokeScenario native(44100,preset);native.set_controls(pedal,0,1);
|
||||
const auto step=[&]{native.crankshaft.omega=rpm*2*pi/60;return native.process();};
|
||||
for(int i=0;i<512;++i)step();const auto before=native.snapshot().solver_substeps;const double elapsed=measure(step,frames);const auto after=native.snapshot();
|
||||
float shortest=1e9f;unsigned cells=0;for(auto& p:native.pipes){cells+=unsigned(p.cells.size());shortest=std::min(shortest,p.minimum_cell_length());}
|
||||
std::cout<<id<<",native,"<<frames<<','<<rpm<<','<<pedal<<','<<cells<<','<<shortest*1000<<','<<double(after.solver_substeps-before)/frames<<','<<elapsed*1e6/frames<<','<<elapsed*44100/frames<<','<<after.pipe_positivity_repairs<<std::endl;
|
||||
auto design=machine_from_engine(preset);bench::set_rpm(design,rpm);MachineSimulation machine(design);machine.set_controls(pedal);
|
||||
for(int i=0;i<512;++i)machine.process();const auto start=machine.snapshot().substeps;const double wall=measure([&]{return machine.process();},frames);const auto state=machine.snapshot();
|
||||
std::cout<<id<<",workshop,"<<frames<<','<<rpm<<','<<pedal<<','<<state.cells<<','<<state.minimum_cell_length*1000<<','<<double(state.substeps-start)/frames<<','<<wall*1e6/frames<<','<<wall*44100/frames<<','<<state.positivity_repairs<<std::endl;
|
||||
if(state.positivity_repairs||after.pipe_positivity_repairs)throw std::runtime_error("Benchmark required positivity repairs");
|
||||
}
|
||||
return 0;
|
||||
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}
|
||||
41
tools/machine_render_benchmark.cpp
Normal file
41
tools/machine_render_benchmark.cpp
Normal file
@@ -0,0 +1,41 @@
|
||||
#include "MachineBench.hpp"
|
||||
#include "FluidSim/Desktop/MachineBuilder.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <SFML/OpenGL.hpp>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
using namespace fluidsim::port::desktop;
|
||||
int main(int argc,char** argv)try{
|
||||
const int frames=argc>1?std::stoi(argv[1]):120;
|
||||
const bool play=argc>2&&std::string(argv[2])=="--play";
|
||||
const double speed=argc>3?std::stod(argv[3]):1.;
|
||||
if(!std::isfinite(speed)||speed<.01||speed>1)throw std::invalid_argument("Playback speed must be 0.01..1");
|
||||
if(frames<1)throw std::invalid_argument("frame count must be positive");
|
||||
sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
|
||||
sf::RenderWindow window(sf::VideoMode({1440,900}),"Workshop render benchmark",sf::Style::Default,sf::State::Windowed,context);
|
||||
window.setVisible(false);window.setVerticalSyncEnabled(false);
|
||||
sf::Font font;bool loaded=false;
|
||||
for(const auto* path:{"C:/Windows/Fonts/consola.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"})if(std::filesystem::exists(path)&&font.openFromFile(path)){loaded=true;break;}
|
||||
std::cout<<(play?"scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered\n":"scenario,frames,ms_per_frame,render_fps\n");
|
||||
for(const char* id:{"flat6","v8-h"}){
|
||||
MachineBuilder builder;auto design=machine_from_engine(scenario_example(id));if(play){bench::set_rpm(design,3000);unsigned cylinder=0,pipe=0;for(const auto& p:design.parts){if(!cylinder&&p.kind==MachinePartKind::Cylinder)cylinder=p.id;if(p.name=="Exhaust atmosphere")for(const auto& link:design.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=design.find(other);part&&part->kind==MachinePartKind::Pipe)pipe=other;}}
|
||||
design.probes={{1,cylinder,"Cylinder pressure",MachineQuantity::Pressure},{2,pipe,"Downpipe lambda",MachineQuantity::Lambda},{3,cylinder,"Fresh charge",MachineQuantity::ChargeAfr},{4,pipe,"Downpipe AFR",MachineQuantity::AirFuelRatio},{5,pipe,"Downpipe flow",MachineQuantity::MassFlow},{6,0,"Shaft RPM",MachineQuantity::Rpm},{7,0,"Shaft torque",MachineQuantity::Torque},{8,0,"Shaft power",MachineQuantity::Power}};}
|
||||
builder.set_document(std::move(design));builder.set_test_speed(speed);
|
||||
if(play){window.setFramerateLimit(60);builder.start_test();if(!builder.testing())throw std::runtime_error("Could not start workshop playback");builder.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::M},window.getSize());
|
||||
for(int i=0;i<16;++i){builder.update();builder.draw(window,loaded?&font:nullptr);window.display();}
|
||||
const auto before=builder.playback_stats();const auto begin=std::chrono::steady_clock::now();
|
||||
for(int i=0;i<frames;++i){builder.update();if(!builder.testing())throw std::runtime_error("Workshop playback stopped");builder.draw(window,loaded?&font:nullptr);window.display();}
|
||||
builder.update();const auto after=builder.playback_stats();const auto wall=std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count();
|
||||
std::cout<<id<<','<<speed<<','<<frames<<','<<wall<<','<<after.simulation_seconds-before.simulation_seconds<<','<<(after.simulation_seconds-before.simulation_seconds)/wall<<','<<after.underruns-before.underruns<<','<<after.buffered<<std::endl;
|
||||
builder.stop_test();continue;
|
||||
}
|
||||
for(int i=0;i<8;++i){builder.draw(window,loaded?&font:nullptr);glFinish();}
|
||||
const auto begin=std::chrono::steady_clock::now();
|
||||
for(int i=0;i<frames;++i){builder.draw(window,loaded?&font:nullptr);glFinish();}
|
||||
const auto elapsed=std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count();
|
||||
std::cout<<id<<','<<frames<<','<<elapsed*1000/frames<<','<<frames/elapsed<<std::endl;
|
||||
}
|
||||
return 0;
|
||||
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}
|
||||
9
verification/performance/2026-10-30/workshop-after.csv
Normal file
9
verification/performance/2026-10-30/workshop-after.csv
Normal file
@@ -0,0 +1,9 @@
|
||||
scenario,runtime,frames,rpm,pedal,cells,min_cell_mm,substeps_per_frame,us_per_frame,wall_per_sim,positivity_repairs
|
||||
single-450,native,32768,3000,0.35,44,43.7834,1,2.89427,0.127637,0
|
||||
single-450,workshop,32768,3000,0.35,44,36.6667,1,3.54769,0.156453,0
|
||||
twin,native,32768,3000,0.35,59,18.9302,1.70621,6.11425,0.269638,0
|
||||
twin,workshop,32768,3000,0.35,58,15,1.71796,6.89146,0.303913,0
|
||||
flat6,native,32768,3000,0.35,98,34.1958,1.03406,8.42729,0.371643,0
|
||||
flat6,workshop,32768,3000,0.35,98,34.1958,1.02969,8.79167,0.387713,0
|
||||
v8-h,native,32768,3000,0.35,176,36.3695,1,16.5588,0.730241,0
|
||||
v8-h,workshop,32768,3000,0.35,175,36.3695,1,16.0058,0.705858,0
|
||||
|
8
verification/performance/2026-10-30/workshop-before.txt
Normal file
8
verification/performance/2026-10-30/workshop-before.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
single-450 native: cells=44 min_cell_mm=43.7834 substeps/frame=1 us/frame=3.17636 realtime_ratio=0.140078
|
||||
single-450 workshop: cells=56 min_cell_mm=5 culprit=Inlet metering stub substeps/frame=4 us/frame=12.6059 realtime_ratio=0.555921 slowdown=3.96866
|
||||
twin native: cells=59 min_cell_mm=18.9302 substeps/frame=1.70621 us/frame=6.21104 realtime_ratio=0.273907
|
||||
twin workshop: cells=67 min_cell_mm=5 culprit=Inlet metering stub substeps/frame=4 us/frame=16.9562 realtime_ratio=0.747767 slowdown=2.73001
|
||||
flat6 native: cells=98 min_cell_mm=34.1958 substeps/frame=1.03406 us/frame=9.63491 realtime_ratio=0.424899
|
||||
flat6 workshop: cells=161 min_cell_mm=1.7773 culprit=Pipe 12 / flat6 substeps/frame=12.3495 us/frame=104.441 realtime_ratio=4.60584 slowdown=10.8398
|
||||
v8-h native: cells=176 min_cell_mm=36.3695 substeps/frame=1 us/frame=15.5274 realtime_ratio=0.684757
|
||||
v8-h workshop: cells=318 min_cell_mm=4.99998 culprit=Inlet metering stub substeps/frame=4.08749 us/frame=67.5066 realtime_ratio=2.97704 slowdown=4.34759
|
||||
@@ -0,0 +1,9 @@
|
||||
scenario,runtime,frames,rpm,pedal,cells,min_cell_mm,substeps_per_frame,us_per_frame,wall_per_sim,positivity_repairs
|
||||
single-450,native,32768,6000,0.7,44,43.7834,1,2.9588,0.130483,0
|
||||
single-450,workshop,32768,6000,0.7,44,36.6667,1,3.56339,0.157145,0
|
||||
twin,native,32768,6000,0.7,59,18.9302,2.03198,7.14645,0.315158,0
|
||||
twin,workshop,32768,6000,0.7,58,15,1.98846,8.3636,0.368835,0
|
||||
flat6,native,32768,6000,0.7,98,34.1958,1.04791,9.21917,0.406565,0
|
||||
flat6,workshop,32768,6000,0.7,98,34.1958,1.04437,9.31432,0.410762,0
|
||||
v8-h,native,32768,6000,0.7,176,36.3695,1.0159,16.3243,0.719902,0
|
||||
v8-h,workshop,32768,6000,0.7,175,36.3695,1,16.1124,0.710557,0
|
||||
|
@@ -0,0 +1,3 @@
|
||||
scenario,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
|
||||
flat6,120,2.00608,2.02014,1.00701,0,4096
|
||||
v8-h,120,2.0706,2.08181,1.00541,0,4096
|
||||
|
@@ -0,0 +1,3 @@
|
||||
scenario,frames,ms_per_frame,render_fps
|
||||
flat6,120,0.696523,1435.7
|
||||
v8-h,120,0.963612,1037.76
|
||||
|
@@ -0,0 +1,3 @@
|
||||
scenario,frames,ms_per_frame,render_fps
|
||||
flat6,120,1.40442,712.035
|
||||
v8-h,120,1.77209,564.305
|
||||
|
95
verification/performance/2026-10-30/workshop-report.md
Normal file
95
verification/performance/2026-10-30/workshop-report.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Workshop performance, 2026-10-30
|
||||
|
||||
The flat-six workshop now runs within the real-time audio budget. The measured
|
||||
simulation cost fell from 104.44 to 8.79 microseconds per sample (11.9 times faster).
|
||||
At 44,100 Hz the available budget is 22.68 microseconds per sample.
|
||||
|
||||
## Cause and changes
|
||||
|
||||
The workshop used a different assembly path from the native scenarios. It split
|
||||
fluid sections at every visual pipe knot, inserted 5 mm inlet stubs, required at
|
||||
least four fluid substeps per sample, and repeated mechanical/control work in
|
||||
each substep. Flat6 consequently had a 1.78 mm cell and averaged 12.35 substeps
|
||||
per sample. Its native scenario had a 34.20 mm minimum cell and about one substep.
|
||||
|
||||
- Mesh each pipe by total arclength and its diameter profile. Visual knots remain
|
||||
in the editor and renderer; exact frustum averages preserve gas volume.
|
||||
- Advance shaft, ECU, cams, combustion, carburetors and pump displacement at the
|
||||
audio sample rate, matching native engine scheduling. Keep CFL stepping for
|
||||
fluid transport and pressure-operated check valves. Compile active part/gate
|
||||
lists and cache fixed restriction areas.
|
||||
- Place the importer's metering assembly farther upstream within its existing
|
||||
inlet segment, allowing a sensible outlet stub. The inlet endpoints, route
|
||||
length, diameter and total gas volume stay the same. Existing custom pipes are
|
||||
never lengthened automatically; actual short pipes still constrain CFL steps.
|
||||
- Cache local pipe vertices, normals and indices, shared primitive meshes, and
|
||||
the part-picker icon atlas. Transforms and pressure updates reuse geometry;
|
||||
route/diameter edits rebuild the affected pipe.
|
||||
|
||||
Untouched stock imports in `machines/` were compared against the previous
|
||||
importer's canonical output before replacement. User files, including
|
||||
`paralell-twin.machine.toml`, were preserved.
|
||||
|
||||
## Simulation results
|
||||
|
||||
Windows GCC RelWithDebInfo, Rusanov, preset density 20 cells/m, 44,100 Hz,
|
||||
512 warm-up samples and 32,768 measured samples, motor-driven at 3,000 RPM,
|
||||
35% pedal. Timings include audio synthesis but exclude editor rendering and
|
||||
snapshot collection. Paired native/workshop measurements use the same process;
|
||||
before/after runs have normal timing variation.
|
||||
|
||||
| Preset | Workshop before, us/sample | Workshop after | Native after | Workshop cells before / after | Substeps before / after |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Single 450 | 12.61 | 3.55 | 2.89 | 56 / 44 | 4.00 / 1.00 |
|
||||
| Twin | 16.96 | 6.89 | 6.11 | 67 / 58 | 4.00 / 1.72 |
|
||||
| Flat6 | 104.44 | 8.79 | 8.43 | 161 / 98 | 12.35 / 1.03 |
|
||||
| V8 H-pipe | 67.51 | 16.01 | 16.56 | 318 / 175 | 4.09 / 1.00 |
|
||||
|
||||
At 6,000 RPM and 70% pedal, flat6 measured 9.31 us/sample and V8 16.11.
|
||||
All benchmark cases reported zero positivity repairs and finite audio.
|
||||
See [baseline](workshop-before.txt), [after](workshop-after.csv) and
|
||||
[higher-load results](workshop-high-load.csv).
|
||||
|
||||
These are performance comparisons, not claims of identical combustion or sound.
|
||||
The workshop has explicit metering-body storage, connections and editable
|
||||
calibration; the native scenarios assemble that hardware differently. Arbitrary
|
||||
large or finely meshed custom machines can still exceed the audio budget.
|
||||
|
||||
## Rendering and playback
|
||||
|
||||
Hidden 1440 x 900 OpenGL window, 120 static frames, `glFinish`, no vsync or
|
||||
simulation: flat6 fell from 1.40 to 0.70 ms/frame; V8 from 1.77 to 0.96 ms/frame.
|
||||
This measures CPU/GPU render cost, not the displayed frame rate.
|
||||
See [before](workshop-render-before.csv) and [after](workshop-render-after.csv).
|
||||
|
||||
The combined workshop/audio check ran at 60 display frames/s with a held
|
||||
3,000 RPM shaft, 16 warm-up frames and 120 measured frames per machine:
|
||||
|
||||
| Preset | Wall seconds | Simulated seconds | Audio underruns | Buffered samples |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Flat6 | 2.006 | 2.020 | 0 | 4,096 |
|
||||
| V8 H-pipe | 2.071 | 2.082 | 0 | 4,096 |
|
||||
|
||||
Simulation time includes producer buffering and snapshot timing, so a small
|
||||
offset from wall time is expected. This is a short playback check, not a long
|
||||
session guarantee. Raw output: [playback](workshop-playback.csv).
|
||||
|
||||
## Validation and reproduction
|
||||
|
||||
All 22 CTest cases passed. Machine tests cover import length/volume preservation,
|
||||
route-knot-independent meshes, a real 5 mm pipe retaining its CFL constraint,
|
||||
explicit fuel sources, cold starting and ECU cutoff. Pump pressure agreed within
|
||||
1% between 44.1 and 88.2 kHz. Builder checks cover geometry-cache reuse and
|
||||
invalidation, pressure coloring, transforms, deletion/ID reuse and the icon atlas.
|
||||
Editor preview images were visually inspected.
|
||||
|
||||
From the repository root after `cmake --build --preset windows`:
|
||||
|
||||
```powershell
|
||||
ctest --preset windows -j 8 --output-on-failure
|
||||
.\out\build\windows-gcc\fluidsim_machine_benchmark.exe 32768 3000 0.35
|
||||
.\out\build\windows-gcc\fluidsim_machine_benchmark.exe 32768 6000 0.7
|
||||
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120
|
||||
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play
|
||||
.\out\build\windows-gcc\fluidsim_machine_builder_tests.exe --preview out/workshop-performance-preview
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
|
||||
flat6,0.01,120,1.94593,0.0196145,0.0100798,0,41
|
||||
v8-h,0.01,120,1.96952,0.0198639,0.0100857,0,41
|
||||
|
@@ -0,0 +1,3 @@
|
||||
scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
|
||||
flat6,0.1,120,1.95923,0.198458,0.101294,0,407
|
||||
v8-h,0.1,120,1.96564,0.19737,0.10041,0,409
|
||||
|
@@ -0,0 +1,3 @@
|
||||
scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
|
||||
flat6,1,120,1.96809,1.98531,1.00875,0,4096
|
||||
v8-h,1,120,2.0466,2.04989,1.0016,0,3872
|
||||
|
99
verification/performance/2026-10-31/workshop-instruments.md
Normal file
99
verification/performance/2026-10-31/workshop-instruments.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Workshop instruments, slow playback and live tuning
|
||||
|
||||
Implemented on 2026-10-31. The workshop now supports eight nonintrusive instrument
|
||||
channels, simulation-time traces, CSV export, slow playback down to 1%, paused
|
||||
10 ms stepping, and live adjustment of safe component settings.
|
||||
|
||||
## Measurement model
|
||||
|
||||
Pressure, temperature and mass-flow taps read the compiled simulation. Pipe taps
|
||||
sample the cell at a chosen fraction of arclength. Lumped-part flow is net into
|
||||
the part; pipe flow is signed Start-to-End. RPM is instantaneous shaft speed;
|
||||
torque and power use the shaft model's running averages.
|
||||
|
||||
Bulk AFR/lambda use a passive conserved fuel-origin tracer. Combustion does not
|
||||
erase that tracer, so downstream instruments include fuel that has already burned.
|
||||
It travels through pipe fluxes, component ports, storage volumes and junctions.
|
||||
The extra tracer does not feed back into gas pressure, energy or mass.
|
||||
Bulk AFR = (gas mass - fuel-origin mass) / fuel-origin mass; lambda divides that
|
||||
by the channel's stoichiometric AFR reference, default 14.7. Fresh-charge AFR on a
|
||||
cylinder instead reads its remaining unburned fresh-air/fuel mass ratio.
|
||||
|
||||
Undefined readings are gaps on the graph and empty cells in CSV. Sensor heating,
|
||||
response lag, oxygen chemistry and fuel-specific combustion chemistry are not
|
||||
added by this change. Initially clean exhaust pipes can read very lean until
|
||||
purged. A probe after a collector measures the gas mixture that actually reaches
|
||||
that collector's outlet, including dilution and backflow.
|
||||
|
||||
Channels sample every 16 physics samples: 2,756.25 Hz at a 44,100 Hz simulation
|
||||
rate. The bounded capture retains 32,768 records (about 11.9 simulated seconds).
|
||||
Graph compression preserves sampled min/max envelopes and leaves undefined
|
||||
intervals disconnected. Frequencies above 1,378.125 Hz cannot be resolved by this
|
||||
capture rate. This is not an audio-rate acquisition mode.
|
||||
|
||||
## Playback and tuning
|
||||
|
||||
Slow playback resamples audio without changing the physics timestep. Producer
|
||||
batch size, prefill and target queue depth scale with playback speed. Pause stops
|
||||
both audio and physics; switching speed discards queued audio while both threads
|
||||
are stopped, preserving the machine's state. A paused step advances exactly 441
|
||||
samples (10 ms). Graph timestamps remain simulated seconds.
|
||||
|
||||
Live edits are validated and applied under the simulation mutex between batches.
|
||||
Carb calibration, ECU settings, throttle opening, cracking pressure, combustion
|
||||
settings and shaft/load settings retain gas state, fuel film, controller state and
|
||||
simulation time. Geometry, initial conditions, mesh/solver settings, cam/valve
|
||||
mechanics and compiled boundary settings remain locked. The API rejects structural
|
||||
changes even if a caller bypasses the UI. Live edits support undo/redo and saving.
|
||||
Probes/taps can change live; changing the capture schema clears the trace.
|
||||
|
||||
Machine files now use version 4 for probe definitions. Versions 1 through 3 remain
|
||||
readable; recordings are exported separately. Existing user machine files were
|
||||
not rewritten.
|
||||
|
||||
## Playback checks
|
||||
|
||||
Windows GCC RelWithDebInfo; hidden 1440 x 900 window at a 60 Hz display target;
|
||||
16 warm-up display frames, 120 measured display frames; motor-driven at 3,000 RPM.
|
||||
All eight channels were active, including cylinder pressure, downpipe AFR/lambda,
|
||||
fresh-charge AFR, mass flow and shaft gauges, with the scope visible.
|
||||
|
||||
| Machine | Requested speed | Measured simulated / wall time | Audio underruns | Buffered input samples |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Flat6 | 1.0 | 1.00875 | 0 | 4,096 |
|
||||
| V8 H-pipe | 1.0 | 1.00160 | 0 | 3,872 |
|
||||
| Flat6 | 0.1 | 0.101294 | 0 | 407 |
|
||||
| V8 H-pipe | 0.1 | 0.100410 | 0 | 409 |
|
||||
| Flat6 | 0.01 | 0.0100798 | 0 | 41 |
|
||||
| V8 H-pipe | 0.01 | 0.0100857 | 0 | 41 |
|
||||
|
||||
The small offsets reflect queue depth, snapshot timing and short-run measurement
|
||||
variation. These are short playback checks, not a sustained-session guarantee.
|
||||
Raw results: [1x](instruments-playback-1.csv), [0.1x](instruments-playback-0.1.csv),
|
||||
[0.01x](instruments-playback-0.01.csv).
|
||||
|
||||
## Verification
|
||||
|
||||
The full 22-test CTest suite passed. Targeted checks additionally cover conserved
|
||||
burned-fuel transport in all four solvers, characteristic/legacy junction mixing,
|
||||
finite exhaust AFR after combustion, lambda normalization, undefined dry readings,
|
||||
probe persistence, live tuning without state/time reset, rejected geometry edits,
|
||||
increased fuel delivery after live carb jet changes, pause/step/slow playback,
|
||||
live undo/redo, CSV units and independently selectable overlapping probe markers.
|
||||
|
||||
Builder preview mode verifies rendered UI pixels after OpenGL scene/icon rendering
|
||||
and captures pressure, downpipe lambda and live carb-tuning views. Those images
|
||||
were visually inspected. Extremely small passive tracer tails are flushed like
|
||||
the existing species tails to avoid subnormal arithmetic overhead; total gas mass
|
||||
and energy are unchanged.
|
||||
|
||||
Reproduce from the repository root:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset windows
|
||||
ctest --preset windows -j 8 --output-on-failure
|
||||
.\out\build\windows-gcc\fluidsim_machine_builder_tests.exe --preview out/workshop-instruments-preview
|
||||
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play 1
|
||||
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play 0.1
|
||||
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play 0.01
|
||||
```
|
||||
Reference in New Issue
Block a user