Improve physical geometry, fluid solvers, audio, and profiling
This commit is contained in:
172
tools/acoustic_benchmark.cpp
Normal file
172
tools/acoustic_benchmark.cpp
Normal file
@@ -0,0 +1,172 @@
|
||||
#include "FluidSim/Junction.hpp"
|
||||
#include "FluidSim/Solver.hpp"
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <complex>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iomanip>
|
||||
#include <string>
|
||||
using namespace fluidsim::port;
|
||||
|
||||
namespace {
|
||||
constexpr double pressure=101325, area=.001;
|
||||
struct Result { double incident{}, reflected{}, transmitted{}, mass_drift{}, energy_drift{};
|
||||
std::array<std::complex<double>,2> incident_spectrum{}, reflected_spectrum{}, transmitted_spectrum{};
|
||||
std::uint64_t repairs{}, limited{}, steps{}, fallbacks{}; };
|
||||
Result run(const std::string& kind,int n,float courant,PipeSolver scheme,
|
||||
double volume,float discharge,const std::filesystem::path& trace) {
|
||||
const bool straight=kind=="straight", split=kind=="split", star=kind=="star" || kind=="legacy-star" || kind=="star-unequal";
|
||||
const int branches=star || kind=="volume-y" ? 3 : 2;
|
||||
Junction chamber(float(volume),pressure,300);
|
||||
const double rho=chamber.density(), c=std::sqrt(1.4*pressure/rho), z=rho*c;
|
||||
std::vector<Pipe1D> pipes;
|
||||
if(straight) pipes.emplace_back(area,4,2*n);
|
||||
else for(int j=0;j<branches;++j) pipes.emplace_back(area*(kind=="star-unequal"?j+1:1),2,n);
|
||||
for(auto& p:pipes) {
|
||||
p.solver=scheme; p.damping_multiplier=0; p.energy_relaxation_rate=0;
|
||||
for(int i=0;i<int(p.cells.size());++i) {
|
||||
const double x=(i+.5)*p.dx;
|
||||
const double pulse=&p==&pipes[0] ? 100*std::exp(-std::pow((x-.6)/.12,2)) : 0;
|
||||
const double density=rho+pulse/(c*c), u=pulse/z;
|
||||
p.cells[i]={float(density),float(density*u),float((pressure+pulse)/pipe_gm1+.5*density*u*u),1,0};
|
||||
}
|
||||
}
|
||||
BoundarySystem boundaries(pipes);
|
||||
boundaries.add_closed_end(0,true);
|
||||
if(straight) boundaries.add_closed_end(0,false);
|
||||
else {
|
||||
for(int j=1;j<branches;++j) boundaries.add_closed_end(j,false);
|
||||
if(split) boundaries.add_connection(0,false,1,true);
|
||||
else if(star) boundaries.add_junction({{0,false},{1,true},{2,true}},kind=="legacy-star"?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic);
|
||||
else {
|
||||
std::vector<JunctionEnd> ends{{0,false,discharge},{1,true,discharge}};
|
||||
if(branches==3) ends.push_back({2,true,discharge});
|
||||
chamber.connect(boundaries,pipes,ends);
|
||||
}
|
||||
}
|
||||
Solver solver(pipes,boundaries); solver.minimum_substeps=1;
|
||||
const bool has_volume=!straight && !split && !star;
|
||||
if(has_volume) solver.add_component(chamber);
|
||||
auto totals=[&] {
|
||||
std::array<double,2> t{has_volume?chamber.mass():0,has_volume?chamber.internal_energy():0};
|
||||
for(const auto& p:pipes) for(int i=0;i<int(p.cells.size());++i) {
|
||||
t[0]+=p.cell_mass(i); t[1]+=double(p.cells[i].energy)*p.cell_area(i)*p.cell_length(i);
|
||||
}
|
||||
return t;
|
||||
};
|
||||
const auto initial=totals(); Result r;
|
||||
std::ofstream out(trace);
|
||||
if(!out) throw std::runtime_error("cannot open trace");
|
||||
out<<std::setprecision(10);
|
||||
out<<"time_s,upstream_pressure_pa,upstream_velocity_m_s,downstream_pressure_pa,downstream_velocity_m_s\n";
|
||||
const int up=int(1.4/pipes[0].dx);
|
||||
const int down=int((straight?2.6:.6)/pipes.back().dx);
|
||||
double time=0;
|
||||
// Stop before far-end reflections return to either probe. Integrate acoustic
|
||||
// characteristics p+/- = (p' +/- rho*c*u)/2, not squared total pressure.
|
||||
while(time<.009) {
|
||||
const float dt=float(std::min(.009-time,courant*(2./n)/(c+.5)));
|
||||
const auto a=pipes[0].state(up), b=pipes.back().state(down);
|
||||
const double plus=(a.pressure-pressure+z*a.velocity)/2;
|
||||
const double minus=(a.pressure-pressure-z*a.velocity)/2;
|
||||
for(int frequency=0;frequency<2;++frequency) {
|
||||
const double hz=frequency==0?500:1000;
|
||||
const auto weight=std::exp(std::complex<double>(0,-2*3.141592653589793*hz*time))*double(dt);
|
||||
r.incident_spectrum[frequency]+=plus*weight;
|
||||
r.reflected_spectrum[frequency]+=minus*weight;
|
||||
r.transmitted_spectrum[frequency]+=(b.pressure-pressure+z*b.velocity)*.5*weight;
|
||||
}
|
||||
r.incident+=plus*plus*area/z*dt;
|
||||
r.reflected+=minus*minus*area/z*dt;
|
||||
if(straight) {
|
||||
const double v=(b.pressure-pressure+z*b.velocity)/2;
|
||||
r.transmitted+=v*v*area/z*dt;
|
||||
} else for(int j=1;j<branches;++j) {
|
||||
const auto q=pipes[j].state(down);
|
||||
const double v=(q.pressure-pressure+z*q.velocity)/2;
|
||||
r.transmitted+=v*v*pipes[j].cell_area(down)/z*dt;
|
||||
}
|
||||
out<<time<<','<<a.pressure-pressure<<','<<a.velocity<<','<<b.pressure-pressure<<','<<b.velocity<<'\n';
|
||||
solver.step(dt); time+=dt;
|
||||
}
|
||||
const auto final=totals();
|
||||
r.mass_drift=(final[0]-initial[0])/initial[0];
|
||||
r.energy_drift=(final[1]-initial[1])/initial[1];
|
||||
for(auto& p:pipes) r.repairs+=p.positivity_repairs;
|
||||
r.fallbacks=boundaries.junction_fallbacks;
|
||||
r.limited=boundaries.limited_fluxes; r.steps=solver.substep_count();
|
||||
if(!(r.incident>0) || !std::isfinite(r.transmitted) || r.repairs ||
|
||||
std::abs(r.mass_drift)>1e-4 || std::abs(r.energy_drift)>1e-4)
|
||||
throw std::runtime_error("acoustic benchmark robustness/conservation failure: "+trace.string()+" mass="+std::to_string(r.mass_drift)+" energy="+std::to_string(r.energy_drift)+" limited="+std::to_string(r.limited)+" repairs="+std::to_string(r.repairs));
|
||||
return r;
|
||||
}
|
||||
}
|
||||
int main(int argc,char** argv) {
|
||||
try {
|
||||
const std::filesystem::path dir=argc>1?argv[1]:"out/acoustics";
|
||||
std::filesystem::create_directories(dir);
|
||||
std::ofstream summary(dir/"summary.csv");
|
||||
summary<<std::setprecision(10);
|
||||
summary<<"case,solver,cells_per_2m,courant,volume_m3,discharge,incident_j,reflected_ratio,transmitted_ratio,unaccounted_ratio,mass_relative_drift,energy_relative_drift,limited_fluxes,positivity_repairs,substeps,transmission_over_straight,reflection_over_straight,ideal_reflected_ratio,ideal_transmitted_ratio,status,junction_fallbacks,t500_gain_over_straight,t500_phase_error_rad,t1000_gain_over_straight,t1000_phase_error_rad\n";
|
||||
double straight_transmission=0, straight_reflection=0, coarse_transmission=0;
|
||||
std::array<double,6> first_order_transmission{};
|
||||
std::array<std::complex<double>,2> straight_spectrum{};
|
||||
int cases=0, flagged=0;
|
||||
for(const auto* scheme:{"rusanov","hlle","hllc","muscl"})
|
||||
for(int n:{40,80,160}) for(float courant:{.4f,.8f})
|
||||
for(const auto* kind:{"straight","split","star","legacy-star","star-unequal","volume","volume-y"})
|
||||
for(double volume:{.00005,.0005,.005}) {
|
||||
if((std::string(kind)=="straight" || std::string(kind)=="split" || (std::string(kind)=="star" || std::string(kind)=="legacy-star" || std::string(kind)=="star-unequal")) && volume!=.00005) continue;
|
||||
for(float discharge:{1.f,.7f}) {
|
||||
if((std::string(kind)=="straight" || std::string(kind)=="split" || (std::string(kind)=="star" || std::string(kind)=="legacy-star" || std::string(kind)=="star-unequal")) && discharge!=1) continue;
|
||||
const std::string id=std::string(kind)+"-"+scheme+"-"+std::to_string(n)+"-"+std::to_string(courant)+"-"+std::to_string(volume)+"-"+std::to_string(discharge);
|
||||
const auto r=run(kind,n,courant,parse_pipe_solver(scheme),volume,discharge,dir/(id+".csv"));
|
||||
const double reflected=r.reflected/r.incident, transmitted=r.transmitted/r.incident;
|
||||
if(std::string(kind)=="straight") {
|
||||
straight_transmission=transmitted; straight_reflection=reflected;
|
||||
for(int frequency=0;frequency<2;++frequency)
|
||||
straight_spectrum[frequency]=r.transmitted_spectrum[frequency]/r.incident_spectrum[frequency];
|
||||
const int slot=(n==40?0:n==80?2:4)+(courant>.5f?1:0);
|
||||
if(std::string(scheme)=="rusanov") first_order_transmission[slot]=transmitted;
|
||||
if(std::string(scheme)=="muscl" && transmitted<first_order_transmission[slot]+.05)
|
||||
throw std::runtime_error("MUSCL pulse attenuation improvement regressed");
|
||||
if(reflected>1e-4 || transmitted<=0 || transmitted>1.01)
|
||||
throw std::runtime_error("straight acoustic propagation regression");
|
||||
if(courant==.4f) {
|
||||
if(n>40 && transmitted<=coarse_transmission)
|
||||
throw std::runtime_error("straight pulse does not converge with mesh refinement");
|
||||
coarse_transmission=transmitted;
|
||||
}
|
||||
}
|
||||
if(std::string(kind)=="split" &&
|
||||
(std::abs(transmitted-straight_transmission)>1e-7 || std::abs(reflected-straight_reflection)>1e-7))
|
||||
throw std::runtime_error("split connection differs from continuous pipe");
|
||||
if(std::string(kind)=="star" || std::string(kind)=="star-unequal") {
|
||||
const double expected_r=std::string(kind)=="star"?1./9:4./9;
|
||||
const double expected_t=1-expected_r;
|
||||
if(std::abs(reflected/straight_transmission-expected_r)>.04 ||
|
||||
std::abs(transmitted/straight_transmission-expected_t)>.07 || r.fallbacks)
|
||||
throw std::runtime_error("characteristic junction acoustic scattering regression: "+id+" R="+std::to_string(reflected/straight_transmission)+" T="+std::to_string(transmitted/straight_transmission)+" fallback="+std::to_string(r.fallbacks));
|
||||
}
|
||||
const bool contaminated=r.limited || reflected+transmitted>1.05;
|
||||
++cases; flagged+=contaminated;
|
||||
summary<<kind<<','<<scheme<<','<<n<<','<<courant<<','<<volume<<','<<discharge<<','<<r.incident<<','<<reflected<<','<<transmitted<<','<<1-reflected-transmitted<<','<<r.mass_drift<<','<<r.energy_drift<<','<<r.limited<<','<<r.repairs<<','<<r.steps<<','<<transmitted/straight_transmission<<','<<reflected/straight_transmission<<',';
|
||||
if(std::string(kind)=="straight" || std::string(kind)=="split") summary<<"0,1,";
|
||||
else if(std::string(kind)=="star-unequal") summary<<4./9<<','<<5./9<<',';
|
||||
else if(std::string(kind)=="star" || std::string(kind)=="legacy-star") summary<<1./9<<','<<8./9<<',';
|
||||
else summary<<",,";
|
||||
summary<<(contaminated?"flagged":"ok")<<','<<r.fallbacks;
|
||||
for(int frequency=0;frequency<2;++frequency) {
|
||||
const auto corrected=r.transmitted_spectrum[frequency]/r.incident_spectrum[frequency]/straight_spectrum[frequency];
|
||||
summary<<','<<std::abs(corrected)<<','<<std::arg(corrected);
|
||||
if(std::string(kind)=="star" && n==160 && (std::abs(std::abs(corrected)-2./3)>.08 || std::abs(std::arg(corrected))>.15))
|
||||
throw std::runtime_error("junction frequency/phase regression: "+id);
|
||||
}
|
||||
summary<<'\n';
|
||||
}
|
||||
}
|
||||
std::cout<<"Validated "<<cases<<" cases ("<<flagged<<" flagged for limiting or acoustic amplification). Wrote acoustic pulse benchmark to "<<dir<<'\n';
|
||||
} catch(const std::exception& e) { std::cerr<<e.what()<<'\n'; return 1; }
|
||||
}
|
||||
276
tools/analyze_scenario_profile.py
Normal file
276
tools/analyze_scenario_profile.py
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a Markdown summary from fluidsim_scenario_profile CSV output."""
|
||||
import csv
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def med(rows, field):
|
||||
return statistics.median(float(row[field]) for row in rows)
|
||||
|
||||
|
||||
def main():
|
||||
source = Path(sys.argv[1] if len(sys.argv) > 1 else "profile-runs.csv")
|
||||
target = Path(sys.argv[2] if len(sys.argv) > 2 else "profile-report.md")
|
||||
rows = list(csv.DictReader(source.open(newline="", encoding="utf-8")))
|
||||
if not rows:
|
||||
raise SystemExit("No profile rows found")
|
||||
engines = sorted({r["scenario"] for r in rows})
|
||||
solvers = ["rusanov", "hlle", "hllc", "muscl"]
|
||||
densities = sorted({int(r["cells_per_meter"]) for r in rows})
|
||||
lines = [
|
||||
"# FluidSim engine and solver performance profile",
|
||||
"",
|
||||
"**Run date:** 2026-10-24 ",
|
||||
"**Raw measurements:** [profile-runs.csv](profile-runs.csv) ",
|
||||
"**Harness:** `tools/scenario_profile.cpp` ",
|
||||
"**Analyzer:** `tools/analyze_scenario_profile.py`",
|
||||
"",
|
||||
"These runs predate the geometry-defined header layouts. Current scenarios have revised header lengths; rerun the harness for current performance figures.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"The solver is the main cost in every tested configuration. At 20 cells/m it takes about 76–92% of measured `process()` time; controls take 3–9%, audio rendering 3–12%, and component updates 2–3% of solver time. Across the run matrix, the median boundary share of solver time falls from 56.5% at 20 cells/m to 30.5% at 80 cells/m; pipe updates rise from 32.8% to 55.0%. The bottleneck shifts from boundary resolution toward pipe-cell advancement as density grows.",
|
||||
"",
|
||||
"Mesh density has a nonlinear runtime effect because smaller cells also require more CFL substeps. Raising density from 20 to 80 cells/m increased median frame cost by 3.0×–6.4× across these engines. The V8 was most expensive: at 80 cells/m it used about 1,054 cells and took about 134 µs per frame in the profiled run.",
|
||||
"",
|
||||
"At a fixed density of 20 cells/m, Rusanov was the fastest solver. Relative to Rusanov at that same density, MUSCL cost 16–50% more depending on engine, while HLLE and HLLC were generally 3–25% more. At your solver-specific listening targets, MUSCL at 25 cells/m is the fastest overall because it reaches your preferred sound at a coarser mesh.",
|
||||
"",
|
||||
"Across all tested scenarios and densities, the median sampled frame cost was 11.6 µs for Rusanov, 13.0 µs for HLLE, 13.5 µs for HLLC, and 15.9 µs for MUSCL. Those pooled values summarize this exact sweep and are affected by its chosen scenario and density mix.",
|
||||
"",
|
||||
"No pipe positivity repairs or HLLC fallbacks occurred in the measured runs.",
|
||||
"",
|
||||
"## Method",
|
||||
"",
|
||||
f"- {len(engines)} TOML scenarios, four solvers, {len(densities)} mesh densities, and two repetitions: {len(rows)} measured runs.",
|
||||
f"- Scenarios: {', '.join(engines)}. `yz-fat` is the two-stroke case; the others are four-stroke.",
|
||||
f"- Densities: {', '.join(map(str, densities))} cells/m, applied to the scenario's configured geometry.",
|
||||
"- Solvers: Rusanov, HLLE, HLLC, and MUSCL. Solver choice was applied to every pipe in each scenario.",
|
||||
"- Each run warmed up for 5,000 frames, then measured 22,050 frames (0.5 simulated seconds) at 44.1 kHz. Engine speed was reset to the scenario's configured initial RPM (or 4,500 RPM when unset) before every frame; the two-stroke was held at 7,000 RPM. Throttle was 55%, load 10%, clutch 0%.",
|
||||
"- Internal profiling sampled every other measured `process()` call. Each run therefore contains 11,025 profiled frames. End-to-end wall time includes all measured frames; stage timings come from sampled frames.",
|
||||
"- Build: Release, MinGW GCC 16.2.0, Windows, Intel Core i5-12600K. The machine was otherwise used interactively, so small differences should not be over-interpreted.",
|
||||
"",
|
||||
"## Cost at 20 cells/m",
|
||||
"",
|
||||
"Each cell shows median end-to-end wall microseconds per frame and wall/simulated-time ratio across the two repetitions. A ratio below 1.0 means this isolated scenario ran faster than real time. Boundary, pipe, and component columns are their share of inclusive solver time; controls and audio are their share of total sampled process time.",
|
||||
"",
|
||||
"| Scenario | Solver | Cells | µs/frame | Wall / sim | Controls | Audio | Boundary / solver | Pipe / solver | Components / solver |",
|
||||
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
|
||||
]
|
||||
for engine in engines:
|
||||
for solver in solvers:
|
||||
subset = [r for r in rows if r["scenario"] == engine and int(r["cells_per_meter"]) == 20 and r["solver"] == solver]
|
||||
if not subset:
|
||||
continue
|
||||
total = med(subset, "total_ms_per_frame")
|
||||
solver_ms = med(subset, "solver_ms_per_frame")
|
||||
boundary = med(subset, "boundary_ms_per_frame") / solver_ms * 100
|
||||
pipe = med(subset, "pipe_ms_per_frame") / solver_ms * 100
|
||||
component = med(subset, "components_ms_per_frame") / solver_ms * 100
|
||||
controls = med(subset, "controls_ms_per_frame") / total * 100
|
||||
audio = med(subset, "audio_ms_per_frame") / total * 100
|
||||
lines.append(f"| {engine} | {solver} | {subset[0]['total_cells']} | {med(subset, 'wall_us_per_frame'):.1f} | {med(subset, 'real_time_factor'):.2f} | {controls:.0f}% | {audio:.0f}% | {boundary:.0f}% | {pipe:.0f}% | {component:.0f}% |")
|
||||
lines += [
|
||||
"",
|
||||
"The three solver sub-stages are nested inside the solver measurement, so their percentages should not be added to the solver percentage. Their shares also do not sum to 100%: solver setup, CFL preparation, and loop overhead make up the remainder.",
|
||||
"",
|
||||
"## Mesh-density scaling",
|
||||
"",
|
||||
"Median profiled total cost per frame, aggregated over all four solvers and both repetitions. Cell counts are the actual total across all pipes in the scenario.",
|
||||
"",
|
||||
"| Scenario | Density: cells / µs | 80 vs 20 cost |",
|
||||
"|---|---|---:|",
|
||||
]
|
||||
for engine in engines:
|
||||
row_parts = []
|
||||
values = {}
|
||||
for density in densities:
|
||||
subset = [r for r in rows if r["scenario"] == engine and int(r["cells_per_meter"]) == density]
|
||||
values[density] = med(subset, "total_ms_per_frame") * 1000
|
||||
row_parts.append(f"{density}: {subset[0]['total_cells']} / {values[density]:.1f}")
|
||||
factor = values[80] / values[20] if 20 in values and 80 in values else 0
|
||||
lines.append(f"| {engine} | {'; '.join(row_parts)} | {factor:.2f}× |")
|
||||
lines += [
|
||||
"",
|
||||
"## Solver cost at 20 cells/m",
|
||||
"",
|
||||
"Change in median `total_ms_per_frame` versus Rusanov for the same scenario and mesh density.",
|
||||
"",
|
||||
"| Scenario | Rusanov | HLLE | HLLC | MUSCL |",
|
||||
"|---|---:|---:|---:|---:|",
|
||||
]
|
||||
for engine in engines:
|
||||
subset = [r for r in rows if r["scenario"] == engine and int(r["cells_per_meter"]) == 20]
|
||||
base = med([r for r in subset if r["solver"] == "rusanov"], "total_ms_per_frame")
|
||||
values = []
|
||||
for solver in solvers:
|
||||
cost = med([r for r in subset if r["solver"] == solver], "total_ms_per_frame")
|
||||
values.append(f"{(cost / base - 1) * 100:+.0f}%")
|
||||
lines.append(f"| {engine} | {' | '.join(values)} |")
|
||||
|
||||
quality_files = [target.parent / f"quality-{solver}.csv" for solver in solvers]
|
||||
scalar_files = [target.parent / f"no-simd-{solver}.csv" for solver in solvers]
|
||||
lazy_files = [target.parent / f"lazy2-{solver}.csv" for solver in solvers]
|
||||
fused_files = [target.parent / f"fused-{solver}.csv" for solver in solvers]
|
||||
density50_file = target.parent / "profile-50-baseline.csv"
|
||||
if density50_file.exists():
|
||||
density50 = list(csv.DictReader(density50_file.open(newline="", encoding="utf-8")))
|
||||
lines += [
|
||||
"",
|
||||
"## All solvers at 50 cells/m",
|
||||
"",
|
||||
"This fixed-density sweep isolates solver cost at your preferred mesh scale. Values are medians across six scenarios and two repetitions; boundary and pipe shares are fractions of inclusive solver time.",
|
||||
"",
|
||||
"[50 cells/m raw runs](profile-50-baseline.csv)",
|
||||
"",
|
||||
"| Solver | Median µs/frame | Boundary / solver | Pipe / solver |",
|
||||
"|---|---:|---:|---:|",
|
||||
]
|
||||
for solver in solvers:
|
||||
subset = [r for r in density50 if r["solver"] == solver]
|
||||
boundary = statistics.median(float(r["boundary_ms_per_frame"]) / float(r["solver_ms_per_frame"]) * 100 for r in subset)
|
||||
pipe = statistics.median(float(r["pipe_ms_per_frame"]) / float(r["solver_ms_per_frame"]) * 100 for r in subset)
|
||||
lines.append(f"| {solver} | {med(subset, 'wall_us_per_frame'):.1f} | {boundary:.1f}% | {pipe:.1f}% |")
|
||||
if all(path.exists() for path in quality_files):
|
||||
quality_rows = [r for path in quality_files for r in csv.DictReader(path.open(newline="", encoding="utf-8"))]
|
||||
combined = target.parent / "quality-target-runs.csv"
|
||||
with combined.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(quality_rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(quality_rows)
|
||||
lines += [
|
||||
"",
|
||||
"## User-rated quality targets",
|
||||
"",
|
||||
"You reported that these densities sound great: Rusanov 70 cells/m, HLLE 60, HLLC 45, and MUSCL 25. The following timings compare those listening targets; the benchmark measures compute and output statistics, not subjective sound quality.",
|
||||
"",
|
||||
"[Quality-target raw runs](quality-target-runs.csv)",
|
||||
"",
|
||||
"| Solver | Cells/m | Median µs/frame (six scenarios) | Median wall/sim | V8 cells | V8 µs/frame | V8 wall/sim |",
|
||||
"|---|---:|---:|---:|---:|---:|---:|",
|
||||
]
|
||||
for solver in solvers:
|
||||
subset = [r for r in quality_rows if r["solver"] == solver]
|
||||
v8 = [r for r in subset if r["scenario"] == "v8-h"]
|
||||
lines.append(f"| {solver} | {subset[0]['cells_per_meter']} | {med(subset, 'wall_us_per_frame'):.1f} | {med(subset, 'real_time_factor'):.2f} | {v8[0]['total_cells']} | {med(v8, 'wall_us_per_frame'):.1f} | {med(v8, 'real_time_factor'):.2f} |")
|
||||
lines += [
|
||||
"",
|
||||
"MUSCL at its 25 cells/m target had the lowest median compute cost across these cases. On the V8, it was about 30 µs/frame versus about 63 µs for HLLC at 45 cells/m, 68 µs for Rusanov at 70, and 76 µs for HLLE at 60. A wall/sim ratio above 1 means the isolated scenario takes longer than real time on this CPU.",
|
||||
]
|
||||
if all(path.exists() for path in scalar_files):
|
||||
scalar_rows = [r for path in scalar_files for r in csv.DictReader(path.open(newline="", encoding="utf-8"))]
|
||||
key = lambda r: (r["scenario"], r["solver"], r["repetition"])
|
||||
fast = {key(r): r for r in quality_rows}
|
||||
scalar = {key(r): r for r in scalar_rows}
|
||||
lines += [
|
||||
"",
|
||||
"### Compiler SIMD comparison",
|
||||
"",
|
||||
"Compared the Release build (GCC auto-vectorization enabled) with `FLUIDSIM_ENABLE_SIMD=OFF`, using the same quality-target matrix and run lengths.",
|
||||
"",
|
||||
"| Solver | Median SIMD speedup | Matched audio output |",
|
||||
"|---|---:|---|",
|
||||
]
|
||||
for solver in solvers:
|
||||
keys = [k for k in fast.keys() & scalar.keys() if k[1] == solver]
|
||||
ratios = [float(scalar[k]["wall_us_per_frame"]) / float(fast[k]["wall_us_per_frame"]) for k in keys]
|
||||
max_audio_delta = max(abs(float(fast[k]["audio_checksum"]) - float(scalar[k]["audio_checksum"])) for k in keys)
|
||||
lines.append(f"| {solver} | {(med([{'v': v} for v in ratios], 'v') - 1) * 100:+.1f}% | audio checksums identical (max delta {max_audio_delta:.0f}) |")
|
||||
lines += [
|
||||
"",
|
||||
"Positive speedup means vectorization was faster. The changes are small and mixed (roughly 0–3% median, within the noise of these short runs), while measured audio RMS, peaks, and checksums matched exactly. The current AoS cell/flux layout is not yielding a large automatic-SIMD gain; an explicit SoA or batched kernel would need a focused prototype and a broader numerical comparison.",
|
||||
"",
|
||||
"Paired raw runs by solver: " + ", ".join(f"{solver} [SIMD](quality-{solver}.csv) / [scalar](no-simd-{solver}.csv)" for solver in solvers) + ".",
|
||||
]
|
||||
if all(path.exists() for path in lazy_files):
|
||||
lazy_rows = [r for path in lazy_files for r in csv.DictReader(path.open(newline="", encoding="utf-8"))]
|
||||
key = lambda r: (r["scenario"], r["solver"], r["repetition"])
|
||||
before = {key(r): r for r in quality_rows}
|
||||
after = {key(r): r for r in lazy_rows}
|
||||
lines += [
|
||||
"",
|
||||
"### Lazy solver scratch buffers",
|
||||
"",
|
||||
"The pipe now allocates MUSCL reconstruction buffers only for MUSCL, and Roe caches only for HLLE/HLLC. Rusanov no longer allocates either unused cache. On the V8 at each solver's quality target:",
|
||||
"",
|
||||
"| Solver | Cells | Scratch before | Scratch after | Saved |",
|
||||
"|---|---:|---:|---:|---:|",
|
||||
]
|
||||
for solver in solvers:
|
||||
k = next(k for k in before if k[0] == "v8-h" and k[1] == solver)
|
||||
b = before[k]
|
||||
a = after[k]
|
||||
old_bytes = 120 * int(b["total_cells"]) + 20 * int(b["pipe_count"])
|
||||
new_bytes = int(a["scratch_bytes"])
|
||||
lines.append(f"| {solver} | {b['total_cells']} | {old_bytes:,} B | {new_bytes:,} B | {old_bytes-new_bytes:,} B ({(old_bytes-new_bytes)/old_bytes*100:.0f}%) |")
|
||||
keys = before.keys() & after.keys()
|
||||
ratios = [float(after[k]["wall_us_per_frame"]) / float(before[k]["wall_us_per_frame"]) for k in keys]
|
||||
max_delta = max(abs(float(after[k]["audio_checksum"]) - float(before[k]["audio_checksum"])) for k in keys)
|
||||
lines += [
|
||||
"",
|
||||
f"The buffer change reduced solver scratch allocation without changing audio checksums (maximum delta {max_delta:.0f}). It did not produce a throughput gain in this short paired comparison (median frame time changed {(med([{'v': v} for v in ratios], 'v')-1)*100:+.1f}% after the change), so keep it as a memory-footprint reduction rather than counting it as a CPU optimization. Scratch stays allocated after a solver is first selected, allowing fast mode switches.",
|
||||
"",
|
||||
"Paired raw runs: " + ", ".join(f"{solver} [before](quality-{solver}.csv) / [after](lazy2-{solver}.csv)" for solver in solvers) + ".",
|
||||
]
|
||||
if all(path.exists() for path in fused_files):
|
||||
fused_rows = [r for path in fused_files for r in csv.DictReader(path.open(newline="", encoding="utf-8"))]
|
||||
fused = {key(r): r for r in fused_rows}
|
||||
common = after.keys() & fused.keys()
|
||||
changes = []
|
||||
for solver in solvers:
|
||||
keys = [k for k in common if k[1] == solver]
|
||||
speedups = [float(after[k]["wall_us_per_frame"]) / float(fused[k]["wall_us_per_frame"]) for k in keys]
|
||||
change = (med([{"v": v} for v in speedups], "v") - 1) * 100
|
||||
changes.append(f"{solver} {change:+.1f}%")
|
||||
audio_delta = max(abs(float(after[k]["audio_checksum"]) - float(fused[k]["audio_checksum"])) for k in common)
|
||||
lines += [
|
||||
"",
|
||||
"A separate experiment fused primitive conversion, state validation, and minimum-timestep reduction into one pipe pass. Paired speed changes were " + ", ".join(changes) + f"; audio checksums stayed identical (maximum delta {audio_delta:.0f}). There was no consistent gain, so that loop change was reverted.",
|
||||
"",
|
||||
"Fused-pass raw runs: " + ", ".join(f"[{solver}](fused-{solver}.csv)" for solver in solvers) + ".",
|
||||
]
|
||||
lines += [
|
||||
"",
|
||||
"## CPU versus memory and threading",
|
||||
"",
|
||||
"A Windows process sample during the 50 cells/m sweep showed about 103% process CPU (approximately one full logical CPU), 7.1 MiB working set, and 3.8 MiB private bytes. The i5-12600K has 10 cores / 16 logical processors. At 50 cells/m, the V8 has 662 pipe cells; its pipe data plus solver scratch is about 96 KiB before the scratch-buffer reduction and about 60 KiB for Rusanov afterward. This is an inference of CPU-bound execution rather than RAM-capacity pressure: the working data is small and fits in the CPU's private L2 cache, but hardware cache-miss/bandwidth counters were not collected, so L1/L2 traffic is not ruled out.",
|
||||
"",
|
||||
"The existing per-step thread benchmark measured the V8's 26 pipes at 50 cells/m: 1 worker took 0.049 s, 2 took 0.188 s, and 4 took 0.267 s for 4,000 steps. Only its artificial 64-pipe × 256-cell case benefited (0.908 s with 1 worker, 0.546 s with 4). Real engine pipes are too small to amortize per-step synchronization; don’t parallelize each pipe step without a persistent task system and a much larger workload.",
|
||||
"",
|
||||
"An experimental GCC `-march=native` build crashed in the five-cylinder 50 cells/m profile with Windows access-violation exit `0xC0000005`; it was rejected. The default portable Release build remains the tested build.",
|
||||
"",
|
||||
"The focused post-change test run passed 8/8: mesh resolution, junctions, scenario catalog, Rusanov/HLLE/HLLC/MUSCL fluid validation, and outdoor audio.",
|
||||
]
|
||||
lines += [
|
||||
"",
|
||||
"## Interpretation and limits",
|
||||
"",
|
||||
"### Optimization priorities",
|
||||
"",
|
||||
"1. For the 50 cells/m listening range, start with pipe advancement for HLLE/HLLC: pipe updates use about 51–57% of solver time. At your target densities, they remain the largest stage for the V8 with Rusanov 70, HLLE 60, and HLLC 45. Reducing unnecessary per-cell passes or improving the cell/flux layout is the most promising CPU work.",
|
||||
"2. Boundary resolution is still a large second target (about 30–47% of solver time at 50 cells/m), and is the largest stage for the twin and for MUSCL in several cases. Optimize the characteristic-junction root solve only with the acoustic junction benchmarks guarding its behavior.",
|
||||
"3. Leave component updates and audio rendering for later; they are smaller absolute costs in the larger engines. Keep optimizing the pipe and boundary stages first.",
|
||||
"",
|
||||
"- Boundary resolution and pipe advancement trade places as density and solver change. At 50 cells/m, boundaries are largest for Rusanov, while pipe advancement leads for HLLE and HLLC; the V8's junction network still makes boundaries substantial.",
|
||||
"- Increasing density raises both the number of cells and the number of solver substeps needed to meet the CFL limit. This explains why runtime growth can exceed the cell-count growth alone.",
|
||||
"- At low densities, controls and audio are a larger fraction of total work because there are few cells to update. Their absolute time stays comparatively small as the mesh grows.",
|
||||
"- Results are for this Release build and machine, with profiling enabled on alternating frames. Instrumentation adds some timing overhead; the tables are best used for relative comparisons within this run. Very short runs, other compilers, build flags, CPUs, and background load can change absolute timings.",
|
||||
"- This benchmark isolates one scenario at a time. It excludes desktop rendering, UI work, output-device scheduling, and audio callback buffering. It does not compare sound quality or engine-state accuracy across mesh densities or solver methods.",
|
||||
"- Two repetitions provide a directional profile, not a high-confidence microbenchmark. Repeat on a quiet machine before using small differences to drive optimization decisions.",
|
||||
"",
|
||||
"## Reproduce",
|
||||
"",
|
||||
"```powershell",
|
||||
"cmake --build out/build/windows-gcc --target fluidsim_scenario_profile -j 8",
|
||||
"out/build/windows-gcc/fluidsim_scenario_profile verification/performance/2026-10-24/profile-runs.csv 5000 22050 2",
|
||||
"py -3 tools/analyze_scenario_profile.py verification/performance/2026-10-24/profile-runs.csv verification/performance/2026-10-24/profile-report.md",
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
target.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"Wrote {target}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
@@ -24,22 +25,21 @@ public:
|
||||
if (!out_)
|
||||
throw std::runtime_error("cannot open WAV: " + path);
|
||||
out_.write("RIFF", 4);
|
||||
integer(36 + frames * 4, 4);
|
||||
integer(36 + frames * 8, 4);
|
||||
out_.write("WAVEfmt ", 8);
|
||||
integer(16, 4);
|
||||
integer(1, 2);
|
||||
integer(3, 2); // IEEE float preserves quiet diagnostic sources.
|
||||
integer(2, 2);
|
||||
integer(44100, 4);
|
||||
integer(44100 * 4, 4);
|
||||
integer(4, 2);
|
||||
integer(16, 2);
|
||||
integer(44100 * 8, 4);
|
||||
integer(8, 2);
|
||||
integer(32, 2);
|
||||
out_.write("data", 4);
|
||||
integer(frames * 4, 4);
|
||||
integer(frames * 8, 4);
|
||||
}
|
||||
void sample(AudioFrame f) {
|
||||
for (float x : {f.left, f.right}) {
|
||||
const auto v = static_cast<std::int16_t>(std::lround(std::clamp(x, -1.f, 1.f) * 32767));
|
||||
integer(static_cast<std::uint16_t>(v), 2);
|
||||
integer(std::bit_cast<std::uint32_t>(x), 4);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -66,7 +66,7 @@ struct Metrics {
|
||||
}
|
||||
};
|
||||
template <class Scenario>
|
||||
void capture(const std::string &prefix, int frames, float pedal, OutdoorScene scene, TwoStrokeConfig config, PipeSolver solver, float fixed_rpm, float warmup, const ScenarioExample* example=nullptr) {
|
||||
void capture(const std::string &prefix, int frames, float pedal, OutdoorScene scene, TwoStrokeConfig config, PipeSolver solver, float fixed_rpm, float warmup, const ScenarioExample* example=nullptr, const std::string& solo="all") {
|
||||
std::unique_ptr<Scenario> s;
|
||||
if constexpr (std::is_same_v<Scenario, TwoStrokeScenario>) s = std::make_unique<Scenario>(44100, config);
|
||||
else if constexpr (std::is_same_v<Scenario, FourStrokeScenario>) {
|
||||
@@ -77,17 +77,34 @@ void capture(const std::string &prefix, int frames, float pedal, OutdoorScene sc
|
||||
s->set_pipe_solver(solver);
|
||||
s->throttle = pedal;
|
||||
s->set_audio_scene(scene);
|
||||
bool valid_solo=solo=="all";
|
||||
for(std::size_t i=0;i<OutdoorAudio::source_names.size();++i) {
|
||||
const bool selected=solo==OutdoorAudio::source_names[i];
|
||||
valid_solo|=selected;
|
||||
s->set_audio_source_enabled(i,solo=="all" || selected);
|
||||
}
|
||||
if(!valid_solo) throw std::runtime_error("choose --solo all, intake, exhaust, exhaust-2, or valve-contact");
|
||||
for(int i=0; i<int(std::lround(warmup*44100)); ++i) {
|
||||
if(fixed_rpm>0) s->crankshaft.omega=fixed_rpm*2*pi/60;
|
||||
s->process();
|
||||
}
|
||||
Wav rendered(prefix + "-rendered.wav", frames), dry(prefix + "-dry.wav", frames),
|
||||
raw(prefix + "-unfiltered.wav", frames);
|
||||
Metrics m, d, r;
|
||||
Metrics m, d, r, tail_metrics;
|
||||
std::array<std::unique_ptr<Wav>,4> stems;
|
||||
std::array<Metrics,4> source_metrics;
|
||||
for(std::size_t i=0;i<stems.size();++i)
|
||||
stems[i]=std::make_unique<Wav>(prefix+"-"+OutdoorAudio::source_names[i]+".wav",frames);
|
||||
Wav tail(prefix+"-tail.wav",frames);
|
||||
int cuts = 0;
|
||||
for (int i = 0; i < frames; i++) {
|
||||
if(fixed_rpm>0) s->crankshaft.omega=fixed_rpm*2*pi/60;
|
||||
auto f = s->process(), df = s->dry_audio(), rf = s->unfiltered_audio();
|
||||
for(std::size_t j=0;j<stems.size();++j) {
|
||||
const auto stem=s->source_audio()[j].rendered;
|
||||
stems[j]->sample(stem); source_metrics[j].add(stem);
|
||||
}
|
||||
tail.sample(s->tail_audio()); tail_metrics.add(s->tail_audio());
|
||||
m.add(f);
|
||||
d.add(df);
|
||||
r.add(rf);
|
||||
@@ -97,16 +114,27 @@ void capture(const std::string &prefix, int frames, float pedal, OutdoorScene sc
|
||||
if (i % 4410 == 0)
|
||||
cuts += s->snapshot().fuel_cut;
|
||||
}
|
||||
for(std::size_t j=0;j<stems.size();++j) source_metrics[j].print(OutdoorAudio::source_names[j]);
|
||||
tail_metrics.print("shared tail");
|
||||
m.print("rendered");
|
||||
d.print("dry filtered");
|
||||
r.print("unfiltered spatial");
|
||||
std::uint64_t repairs=0, fallbacks=0;
|
||||
for(const auto& pipe:s->pipes) { repairs+=pipe.positivity_repairs; fallbacks+=pipe.hllc_fallbacks; }
|
||||
if constexpr (std::is_same_v<Scenario, FourStrokeScenario> || std::is_base_of_v<FourStrokeScenario,Scenario>) {
|
||||
const auto diagnostics=s->snapshot();
|
||||
std::cout<<"junction_model="<<s->junction_config.model<<" junction_fallbacks="<<diagnostics.junction_fallbacks
|
||||
<<" junction_limited_fluxes="<<diagnostics.junction_limited_fluxes<<" junction_iterations="<<diagnostics.junction_iterations<<" limited_fluxes="<<diagnostics.boundary_limited_fluxes<<'\n';
|
||||
}
|
||||
std::cout<<"pipe_positivity_repairs="<<repairs<<" hllc_fallbacks="<<fallbacks<<'\n';
|
||||
std::cout << "fuel-cut checkpoints=" << cuts << " final_rpm=" << s->snapshot().rpm
|
||||
<< " scene=" << scene_name(scene) << '\n';
|
||||
}
|
||||
} // namespace
|
||||
int main(int argc, char **argv) {
|
||||
try {
|
||||
std::string example_id;
|
||||
std::string example_id, solo="all", junction_model;
|
||||
float junction_loss=-1;
|
||||
std::string scenario = "twin", prefix = "build/audio/twin", scene = "wall";
|
||||
float seconds = 6, pedal = 1, fixed_rpm = 0, warmup = 0;
|
||||
TwoStrokeConfig config;
|
||||
@@ -116,7 +144,16 @@ int main(int argc, char **argv) {
|
||||
if (i + 1 >= argc)
|
||||
throw std::runtime_error("missing option value");
|
||||
std::string value = argv[++i];
|
||||
if(key=="--example") example_id=value;
|
||||
if(key=="--junctions") {
|
||||
if(value!="volume" && value!="characteristic" && value!="legacy") throw std::runtime_error("choose volume, characteristic, or legacy junctions");
|
||||
junction_model=value;
|
||||
}
|
||||
else if(key=="--junction-loss") {
|
||||
junction_loss=std::stof(value);
|
||||
if(!std::isfinite(junction_loss) || junction_loss<0 || junction_loss>10) throw std::runtime_error("junction loss must be 0..10");
|
||||
}
|
||||
else if(key=="--solo") solo=value;
|
||||
else if(key=="--example") example_id=value;
|
||||
else if (key == "--scenario")
|
||||
scenario = value;
|
||||
else if (key == "--out")
|
||||
@@ -160,21 +197,30 @@ int main(int argc, char **argv) {
|
||||
if (!std::filesystem::path(prefix).parent_path().empty())
|
||||
std::filesystem::create_directories(std::filesystem::path(prefix).parent_path());
|
||||
const int frames = int(std::lround(seconds * 44100));
|
||||
const auto examples=scenario_examples();
|
||||
auto examples=scenario_examples();
|
||||
if(!junction_model.empty() || junction_loss>=0) {
|
||||
if(example_id.empty()) example_id=scenario=="v8"?"v8-h":scenario=="b205"?"saab-straight":scenario=="twin"?"twin":"yz-racing";
|
||||
for(auto& example:examples) {
|
||||
if(!junction_model.empty()) example.junctions.model=junction_model;
|
||||
if(junction_loss>=0) example.junctions.turning_loss=junction_loss;
|
||||
}
|
||||
}
|
||||
if(!example_id.empty()) {
|
||||
const auto example=std::find_if(examples.begin(),examples.end(),[&](const auto& e){return e.id==example_id;});
|
||||
if(example==examples.end()) throw std::runtime_error("unknown example: "+example_id);
|
||||
if(example->kind==ScenarioKind::TwoStroke && (!junction_model.empty() || junction_loss>=0))
|
||||
throw std::runtime_error("two-stroke exhaust has no configurable pipe junctions");
|
||||
if(example->kind==ScenarioKind::TwoStroke)
|
||||
capture<TwoStrokeScenario>(prefix,frames,pedal,selected,example->two,solver,fixed_rpm,warmup);
|
||||
else capture<FourStrokeScenario>(prefix,frames,pedal,selected,config,solver,fixed_rpm,warmup,&*example);
|
||||
capture<TwoStrokeScenario>(prefix,frames,pedal,selected,example->two,solver,fixed_rpm,warmup,nullptr,solo);
|
||||
else capture<FourStrokeScenario>(prefix,frames,pedal,selected,config,solver,fixed_rpm,warmup,&*example,solo);
|
||||
} else if (scenario == "twin")
|
||||
capture<FourStrokeTwinScenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup);
|
||||
capture<FourStrokeTwinScenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup,nullptr,solo);
|
||||
else if (scenario == "b205")
|
||||
capture<SaabB205Scenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup);
|
||||
capture<SaabB205Scenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup,nullptr,solo);
|
||||
else if (scenario == "two-stroke")
|
||||
capture<TwoStrokeScenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup);
|
||||
capture<TwoStrokeScenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup,nullptr,solo);
|
||||
else if (scenario == "v8")
|
||||
capture<AmericanV8Scenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup);
|
||||
capture<AmericanV8Scenario>(prefix, frames, pedal, selected, config, solver, fixed_rpm, warmup,nullptr,solo);
|
||||
else
|
||||
throw std::runtime_error("choose twin, two-stroke, b205 or v8");
|
||||
} catch (const std::exception &e) {
|
||||
|
||||
59
tools/audio_preview.py
Normal file
59
tools/audio_preview.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Create peak-normalized listening copies of PCM16 or float32 capture WAVs.
|
||||
|
||||
Usage: py tools/audio_preview.py out/audio/single-450-baseline
|
||||
Original files retain calibration; preview levels must not be compared by source.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import wave
|
||||
|
||||
|
||||
def preview(path, destination):
|
||||
data = path.read_bytes()
|
||||
if data[:4] != b"RIFF" or data[8:12] != b"WAVE":
|
||||
raise ValueError(f"Not a WAV: {path}")
|
||||
offset = 12
|
||||
fmt = payload = None
|
||||
while offset + 8 <= len(data):
|
||||
tag, size = struct.unpack_from("<4sI", data, offset)
|
||||
chunk = data[offset + 8:offset + 8 + size]
|
||||
if tag == b"fmt ":
|
||||
fmt = struct.unpack_from("<HHIIHH", chunk)
|
||||
elif tag == b"data":
|
||||
payload = chunk
|
||||
offset += 8 + size + size % 2
|
||||
if fmt is None or payload is None:
|
||||
raise ValueError(f"Missing WAV chunks: {path}")
|
||||
encoding, channels, rate, _, _, bits = fmt
|
||||
if (encoding, bits) == (3, 32):
|
||||
values = [x[0] for x in struct.iter_unpack("<f", payload)]
|
||||
elif (encoding, bits) == (1, 16):
|
||||
values = [x[0] / 32768 for x in struct.iter_unpack("<h", payload)]
|
||||
else:
|
||||
raise ValueError(f"Unsupported WAV format: {path}")
|
||||
if not all(math.isfinite(x) for x in values):
|
||||
raise ValueError(f"Nonfinite audio: {path}")
|
||||
peak = max(map(abs, values), default=0)
|
||||
if peak == 0:
|
||||
print(f"{path.name}: silent; no preview")
|
||||
return
|
||||
gain = 0.8 / peak
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
target = destination / (path.stem + "-preview.wav")
|
||||
with wave.open(str(target), "wb") as out:
|
||||
out.setparams((channels, 2, rate, 0, "NONE", "not compressed"))
|
||||
out.writeframes(b"".join(struct.pack("<h", round(x * gain * 32767)) for x in values))
|
||||
print(f"{target.name}: gain {20 * math.log10(gain):+.1f} dB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("prefix", type=Path)
|
||||
args = parser.parse_args()
|
||||
paths = sorted(args.prefix.parent.glob(args.prefix.name + "-*.wav"))
|
||||
if not paths:
|
||||
parser.error("no matching capture WAVs")
|
||||
for path in paths:
|
||||
preview(path, args.prefix.parent / "previews")
|
||||
9
tools/migrate_geometry.cpp
Normal file
9
tools/migrate_geometry.cpp
Normal file
@@ -0,0 +1,9 @@
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <iostream>
|
||||
int main(int argc,char** argv) try {
|
||||
const auto directory=argc>1?std::filesystem::path(argv[1]):fluidsim::port::engine_directory();
|
||||
const bool relayout=argc>2&&std::string(argv[2])=="--relayout";
|
||||
if(std::filesystem::is_regular_file(directory)){fluidsim::port::migrate_engine_geometry(directory,relayout);return 0;}
|
||||
const auto definitions=fluidsim::port::load_engine_directory(directory);
|
||||
for(const auto& definition:definitions){fluidsim::port::migrate_engine_geometry(definition.source_path,relayout);std::cout<<definition.id<<'\n';}
|
||||
} catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}
|
||||
91
tools/scenario_profile.cpp
Normal file
91
tools/scenario_profile.cpp
Normal file
@@ -0,0 +1,91 @@
|
||||
#include "FluidSim/Scenario.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||
#include <chrono>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <numeric>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace fluidsim::port;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
template<class Scenario>
|
||||
void measure_case(const ScenarioExample& definition, int density, PipeSolver solver,
|
||||
int repetition, int warmup_frames, int measured_frames, Scenario& scenario, std::ostream& out) {
|
||||
scenario.set_pipe_solver(solver);
|
||||
float target_rpm=7000.f;
|
||||
if constexpr (std::is_same_v<Scenario, FourStrokeScenario>) {
|
||||
scenario.set_controls(.55f, .1f, 0.f);
|
||||
target_rpm=definition.engine.initial_rpm > 0 ? definition.engine.initial_rpm : 4500.f;
|
||||
} else {
|
||||
scenario.set_controls(.55f, .1f, 0.f);
|
||||
}
|
||||
for (int i=0;i<warmup_frames;++i) { scenario.crankshaft.omega=target_rpm*2*pi/60; scenario.process(); }
|
||||
scenario.enable_profiling(true);
|
||||
const auto begin=Clock::now();
|
||||
double audio_energy=0,audio_checksum=0;
|
||||
float audio_peak=0;
|
||||
for (int i=0;i<measured_frames;++i) {
|
||||
scenario.crankshaft.omega=target_rpm*2*pi/60;
|
||||
const auto frame=scenario.process();
|
||||
if(!std::isfinite(frame.left)||!std::isfinite(frame.right)) throw std::runtime_error("nonfinite profiled audio");
|
||||
audio_energy+=double(frame.left)*frame.left+double(frame.right)*frame.right;
|
||||
audio_peak=std::max({audio_peak,std::abs(frame.left),std::abs(frame.right)});
|
||||
audio_checksum+=double(frame.left)*(i+1)+double(frame.right)*(i%251+1);
|
||||
}
|
||||
const double wall=std::chrono::duration<double>(Clock::now()-begin).count();
|
||||
const auto& profile=scenario.profile_totals();
|
||||
std::size_t cells=0;
|
||||
std::size_t scratch_bytes=0;
|
||||
for(const auto& pipe:scenario.pipes) { cells+=pipe.cells.size();scratch_bytes+=pipe.scratch_bytes(); }
|
||||
std::uint64_t repairs=0,fallbacks=0;
|
||||
for(const auto& pipe:scenario.pipes) { repairs+=pipe.positivity_repairs; fallbacks+=pipe.hllc_fallbacks; }
|
||||
double ms[7]{};
|
||||
for(int i=0;i<7;++i) ms[i]=profile.sampled_steps ? profile.nanoseconds[i]/1e6/profile.sampled_steps : 0;
|
||||
out<<definition.id<<','<<(definition.kind==ScenarioKind::TwoStroke?"two-stroke":"four-stroke")<<','<<density<<','
|
||||
<<pipe_solver_name(solver)<<','<<repetition<<','<<scenario.pipes.size()<<','<<cells<<','<<measured_frames<<','
|
||||
<<profile.sampled_steps<<','<<std::setprecision(8)<<wall<<','<<wall/measured_frames*1e6<<','<<wall/(measured_frames/44100.0)<<','
|
||||
<<scratch_bytes<<',';
|
||||
for(int i=0;i<7;++i) out<<ms[i]<<',';
|
||||
out<<profile_percentile_ms(profile,.50)<<','<<profile_percentile_ms(profile,.95)<<','<<repairs<<','<<fallbacks<<','
|
||||
<<std::sqrt(audio_energy/(2*measured_frames))<<','<<audio_peak<<','<<audio_checksum<<'\n';
|
||||
}
|
||||
|
||||
int main(int argc,char** argv) {
|
||||
const std::string output=argc>1?argv[1]:"scenario-profile.csv";
|
||||
const int warmup=argc>2?std::stoi(argv[2]):5000;
|
||||
const int frames=argc>3?std::stoi(argv[3]):22050;
|
||||
const int reps=argc>4?std::stoi(argv[4]):2;
|
||||
std::vector<std::string> ids={"single-450","twin","five","v8-h","yz-fat","saab-chambered"};
|
||||
if(argc>6 && std::string(argv[6])!="all") ids={argv[6]};
|
||||
const std::vector<int> densities=argc>5?std::vector<int>{std::stoi(argv[5])}:
|
||||
std::vector<int>{10,20,40,50,80};
|
||||
std::vector<PipeSolver> solvers={PipeSolver::Rusanov,PipeSolver::Hlle,PipeSolver::Hllc,PipeSolver::Muscl};
|
||||
if(argc>7) solvers={parse_pipe_solver(argv[7])};
|
||||
std::ofstream file(output);
|
||||
if(!file) { std::cerr<<"cannot open output: "<<output<<'\n'; return 2; }
|
||||
file<<"scenario,kind,cells_per_meter,solver,repetition,pipe_count,total_cells,frames,sampled_frames,wall_seconds,wall_us_per_frame,real_time_factor,scratch_bytes,total_ms_per_frame,controls_ms_per_frame,solver_ms_per_frame,boundary_ms_per_frame,pipe_ms_per_frame,components_ms_per_frame,audio_ms_per_frame,p50_frame_ms,p95_frame_ms,positivity_repairs,hllc_fallbacks,audio_rms,audio_peak,audio_checksum\n";
|
||||
int completed=0, total=static_cast<int>(ids.size()*densities.size()*solvers.size()*reps);
|
||||
for(const auto& id:ids) for(int density:densities) for(auto solver:solvers) for(int rep=1;rep<=reps;++rep) {
|
||||
const auto& def=scenario_example(id);
|
||||
auto config=def;
|
||||
config.engine.cells_per_meter=static_cast<float>(density);
|
||||
config.two.cells_per_meter=static_cast<float>(density);
|
||||
if(def.kind==ScenarioKind::TwoStroke) {
|
||||
config.two.exhaust_cell_length=1.0f/density;
|
||||
TwoStrokeScenario scenario(44100,config.two);
|
||||
measure_case(def,density,solver,rep,warmup,frames,scenario,file);
|
||||
} else {
|
||||
FourStrokeScenario scenario(44100,config);
|
||||
measure_case(def,density,solver,rep,warmup,frames,scenario,file);
|
||||
}
|
||||
file.flush();
|
||||
std::cerr<<"completed "<<++completed<<"/"<<total<<" "<<id<<" density="<<density<<" solver="<<pipe_solver_name(solver)<<" rep="<<rep<<'\n';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user