diff --git a/.dockerignore b/.dockerignore index 42794237b..3bc9bd68c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,12 +16,20 @@ landing docs scripts/setup-test -# lib/ is excluded wholesale EXCEPT the two shared cores every design-tool -# image builds on -- the Python one the APIs install, and the TypeScript one the -# frontends compile. The negations must come after the exclusion. +# lib/ is excluded wholesale EXCEPT the shared cores that root-context images +# install or compile: the Python design core the APIs install, the TypeScript UI +# the frontends compile, and the feed system physics feed-twin's API is built on. +# The negations must come after the exclusion. +# +# Adding a package under lib/ WITHOUT a negation here is a silent trap: the +# `COPY lib// ...` in its Dockerfile fails with "not found", but only +# inside a real docker build -- every local test passes, because nothing else +# consults this file. If you add a lib/ package that an image needs, add it here +# in the same commit. lib !lib/stardesign !lib/stardesign-ui +!lib/feedtwin # star-openrocket and pid-designer now build from the repo root too, so their # sources have to reach the context -- but only their own image copies them, and @@ -41,6 +49,10 @@ venv **/__pycache__ **/*.pyc **/.pytest_cache +# setuptools metadata from a host `pip install -e`. The image regenerates it +# when it installs the package, so copying the host's in only risks a stale +# SOURCES.txt shadowing the real one. +**/*.egg-info **/.pio **/build diff --git a/.github/workflows/feed-twin-ci.yml b/.github/workflows/feed-twin-ci.yml new file mode 100644 index 000000000..7acccd874 --- /dev/null +++ b/.github/workflows/feed-twin-ci.yml @@ -0,0 +1,265 @@ +name: feed-twin CI + +'on': + push: + paths: + - 'feed-twin/**' + # The physics core. Every number this app produces comes from it, so a + # change there has to run these gates -- including the ones in the app. + - 'lib/feedtwin/**' + # EngineDesign's dependency set: the engine-design-compat job below proves + # the physics core can be installed alongside it, so a change to what + # EngineDesign pins has to re-run that proof. + - 'EngineDesign/requirements-base.txt' + - '.github/workflows/feed-twin-ci.yml' + pull_request: + paths: + - 'feed-twin/**' + - 'lib/feedtwin/**' + - 'EngineDesign/requirements-base.txt' + - '.github/workflows/feed-twin-ci.yml' + workflow_dispatch: + +jobs: + # The physics core, on its own. Deliberately a separate job from the backend: + # the whole premise of ADR-0001 is that this package stands up with no web + # stack installed, and the only way to keep that true is to prove it in an + # environment that has never seen FastAPI. + library: + name: Physics core (lint, types, tests) + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + # 3.12, matching Dockerfile.api's python:3.12-slim -- the library is + # exercised here on the version it actually ships on. The backend job + # below runs 3.11, which is the floor in pyproject.toml, so between + # them both ends of the supported range are covered. + python-version: '3.12' + cache: 'pip' + cache-dependency-path: lib/feedtwin/pyproject.toml + + - name: Install the physics core + # Editable, so mypy and the PropsSI scan see the tree rather than a + # copy in site-packages. Note that nothing here installs fastapi -- + # test_package_carries_no_web_framework depends on that being true. + run: pip install -e "lib/feedtwin[dev]" + + - name: Format (black --check) + # No working-directory: black finds the repo root by walking up to .git + # and reads [tool.black] from the root pyproject.toml, keeping this + # package on the same style as the rest of the repo. + run: black --check lib/feedtwin + + - name: Types (mypy --strict) + # working-directory matters: mypy reads its target from [tool.mypy] in + # lib/feedtwin/pyproject.toml, and discovers that file relative to the + # working directory. Run from the repo root it finds the root + # pyproject.toml, which configures only black, and exits with + # "Missing target module, package, files, or command". + working-directory: lib/feedtwin + run: mypy + + # Two gates, and the second is the reason this phase exists. + # + # test_package.py proves `import feedtwin` works and the physics stack + # resolved -- Phase 00's exit criterion, half of it. + # + # test_property_call_discipline.py fails the build on any PropsSI call in + # library code. That is not style policing: PropsSI rebuilds its backend + # per call and is ~1300x slower than a reused AbstractState (184.5 us vs + # 0.14 us, measured). A stiff transient evaluates properties millions of + # times, and nothing about the slow spelling looks wrong in review. + - name: Tests + run: python -m pytest lib/feedtwin/tests -q + + backend: + name: Backend (imports + tests) + runs-on: ubuntu-latest + # 45, not the 15 the other jobs use. This suite integrates a stand: 197 + # tests, measured at 21 minutes on a developer's machine in this job's own + # dependency set, and a runner core is slower than that. The 15 was never + # tested, because until the format and type steps ahead of it passed the + # tests had never once run here -- the job was cancelled at 15.2 minutes + # the first time it got that far, which reads as a failure and is not one. + # + # `-n auto` with pytest-xdist takes it to about 7 minutes and the suite + # passes clean under it; not adopted yet because a physics suite sharing a + # library directory deserves more than one green parallel run before CI + # depends on it. + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: feed-twin/requirements.txt + + - name: Install dependencies + working-directory: feed-twin + # The physics core is installed from a path, not from requirements.txt + # -- the path that reaches it differs between a checkout and a Docker + # build context. Same arrangement as lib/stardesign in the other apps. + run: | + pip install -r requirements.txt + pip install -e ../lib/feedtwin + + - name: Verify imports + working-directory: feed-twin + run: python3 -c "import backend.main; print('backend imports OK')" + + - name: Install test dependencies + working-directory: feed-twin + # httpx: starlette's TestClient is built on it, and the design-tool + # import tests drive it through an httpx.MockTransport. + # anyio: those tests are async, and it carries the pytest plugin that + # runs them. It arrives transitively with httpx today -- named here + # anyway, because a transitive dependency that disappears would turn an + # async test gate into a silent no-op rather than a failure. + run: pip install pytest httpx anyio black mypy + + # The app gets the same lint and type gates as the library. It is a small + # shell today, which is exactly why holding the line is cheap -- the time + # to discover the backend was never type-checked is not the first time it + # grows a solve endpoint. + - name: Format (black --check) + working-directory: feed-twin + run: black --check backend tests + + - name: Types (mypy --strict) + working-directory: feed-twin + # --ignore-missing-imports, unlike the library job: the app's own + # per-module overrides would be config for one dependency, and FastAPI + # ships its own types anyway. + run: mypy backend --strict --ignore-missing-imports + + # Proves the library imports in the *app's* environment too, which is + # built from a different requirements file than the library job's. Phase + # 00 claims both environments; both are checked. + - name: Run tests + working-directory: feed-twin + run: python -m pytest tests -q + + # Phase 00's other exit criterion: the physics core has to be installable + # alongside EngineDesign, because Layer X will import it in that process + # (ADR-0001). Nothing makes EngineDesign depend on feedtwin yet -- that + # happens in Phase 04, when feed_loss.py starts delegating -- so this proves + # the two dependency sets can coexist *before* anything is riding on it. + # + # The realistic failure is a numpy ceiling: EngineDesign pins numba, numba + # constrains numpy's upper bound, and feedtwin wants numpy >= 1.26. A resolver + # conflict discovered in Phase 04 would be a bad surprise; discovered here it + # is a version bump. + engine-design-compat: + name: Coexists with EngineDesign + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: | + EngineDesign/requirements-base.txt + lib/feedtwin/pyproject.toml + + # requirements-base, not requirements.txt: the latter adds rocketcea, + # which builds NASA CEA from Fortran source and is only needed to + # regenerate the CEA cache. Nothing about dependency resolution needs it. + - name: Install EngineDesign, then the physics core on top + run: | + pip install -r EngineDesign/requirements-base.txt + pip install -e lib/feedtwin + + # pip resolves happily and then warns about incompatibilities rather than + # failing, so check explicitly instead of trusting the exit code above. + - name: No broken dependencies + run: pip check + + # working-directory: EngineDesign because `engine` is a source package in + # that tree, not an installed distribution -- it is only importable with + # EngineDesign/ on sys.path, which is how its own CI runs too. + - name: Both import in one process + working-directory: EngineDesign + run: | + python3 -c " + import feedtwin, engine, numpy, scipy + print('feedtwin', feedtwin.__version__) + print('numpy ', numpy.__version__) + print('scipy ', scipy.__version__) + " + + frontend: + name: Frontend (TypeScript build) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + cache-dependency-path: feed-twin/frontend/package-lock.json + + - name: Install dependencies + working-directory: feed-twin/frontend + run: npm ci || npm install + + # `npm run build` is `tsc -b && vite build`, so this is the type gate as + # well as the build gate. + - name: TypeScript build + working-directory: feed-twin/frontend + run: npm run build + + # Only the pure logic is tested, and deliberately: `src/lib/series.ts` is + # the one part of this frontend that can be *wrong* rather than merely + # ugly. Rendering assertions on a layout that is still moving would cost + # more to maintain than they catch. + - name: Unit tests + working-directory: feed-twin/frontend + run: npm test + + # Docker image build. Proves the images build against the current tree, on a + # PR rather than after the merge -- publish-apps.yml only runs on push to + # main, so without this a Dockerfile that no longer matches the tree breaks + # the deploy image instead of the PR. That is exactly how + # EngineDesign/Dockerfile.api broke main when PR #37 deleted engine/native. + # + # push: false, so this publishes nothing and needs no GHCR login. + docker-image: + name: Docker image (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + include: + - { name: feed-twin-api, context: ., dockerfile: ./feed-twin/Dockerfile.api } + - { name: feed-twin-frontend, context: ., dockerfile: ./feed-twin/frontend/Dockerfile } + + steps: + - uses: actions/checkout@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build (no push) + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: false + cache-from: type=gha,scope=${{ matrix.name }} diff --git a/.gitignore b/.gitignore index dfe28da76..1f17c1480 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,10 @@ daq-server/BACKEND_REVIEW.md # macOS .DS_Store **/.DS_Store + +# Build trees, per-app user data, engine run output: never source. +build/ +**/build/ +.userdata/ +**/.userdata/ +EngineDesign/engine/output/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..63b825486 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# STAR monorepo — notes for agents + +## Before you change physics, and after + +**Run `python3 scripts/physics_benchmark.py`.** It checks the closed-form results in +`docs/PHYSICS-BENCHMARK.md` against hand calculation, `fluids`, CoolProp and the +handbook — not against this codebase — and exits non-zero when one moves. + +Read `docs/PHYSICS-BENCHMARK.md` itself before a substantial change. It carries the +He/GN2 study expectations (too slow for the script), the venting and state-machine +checks, and a list of traps that have each cost a day. + +The rule it opens with is the one that matters: **you cannot verify the sim with the +sim.** A run that converges, plots smoothly and violates no assertion is not evidence. + +## The apps + +| app | what it is | dev ports | +|---|---|---| +| `EngineDesign` | liquid engine design + optimizer (Layers 1–4) | API 8000, UI 5173 | +| `pid-designer` | the P&ID drawing tool | API 8001, UI 5174 | +| `feed-twin` | the feed-system digital twin / cockpit | API 8003, UI 5177 | +| `daq-server` | the real stand's DAQ | — | +| `lib/feedtwin` | the physics library both EngineDesign and feed-twin import | — | +| `lib/stardesign` | the shared document store (checkout, versions, sharing) | — | + +Ports are overridable — `ENGINE_DESIGN_API_PORT`, `PID_DESIGNER_API_PORT`, +`FEED_TWIN_API_PORT`. feed-twin finds its siblings through `ENGINE_DESIGN_URL` and +`PID_DESIGNER_URL`; if a source reports 404 check *what is actually answering on that +port* before concluding the endpoint is missing. + +## Testing + +```bash +cd lib/feedtwin && python3 -m pytest -q && python3 -m mypy feedtwin && python3 -m black --check feedtwin tests +cd feed-twin && python3 -m pytest -q +cd pid-designer && PYTHONPATH=../lib/stardesign python3 -m pytest tests/ -q +cd EngineDesign && PYTHONPATH=../lib/stardesign python3 -m pytest tests/ -q # 4 known failures +``` + +`lib/stardesign` is not installed; the two document-store apps need it on +`PYTHONPATH`. + +**A test that cannot fail is not a test.** Break the thing a new test guards and +confirm it goes red before you trust it. Several tests here have passed against the +very bugs they were written for. + +## Conventions that are load-bearing + +- **Every number describing hardware carries a provenance.** `Param(value, unit, + source, reference)`. There is no default source — "nobody remembers where this came + from" is the state the model layer exists to prevent. When a result looks wrong, + read the provenance of the inputs before doubting the solver. +- **Correlations are adapted, not invented.** `feedtwin.comps.correlations` wraps + `fluids`; if a number disagrees with the book, the fault is in an adapter. Constants + that cannot be adapted are calibrated from the library at import and say so. +- **Named models over tuned coefficients.** Collapse, vapour and geometry are + registries of named models, so a run report can print which assumption produced the + answer. +- **Rebuild dataclasses with `replace`.** Constructing field-by-field silently drops + anything added later; this has bitten `TankState` and `Setup` already. +- **New physics is opt-in and defaults to the previous behaviour**, exactly. Assert + that it does -- and assert the second half too: turned *on* against a drawing that + declares nothing for it, it must also change nothing. Gate on the model having + something to do, not just on the flag. See `docs/PHYSICS-BENCHMARK.md` 2.5. +- **Price arriving gas by where it came from.** An ullage's inflow enthalpy is the + walk's arrival at that node (`Session.arriving_enthalpy`), never "the bottle's" by + assumption: two primed tanks trade grams through a shared press manifold every step, + and pricing the other tank's 293 K gas at bottle enthalpy pumped a helium ullage 15 + psi above lockup with zero regulator flow. When a vessel warms with no net inflow, + print each feeding branch and the enthalpy it is priced at. +- **Close stiff couplings by solving them, not relaxing them.** The chamber node is a + boundary whose value depends on the flows it receives; a relaxed step per tick is a + fixed-point iteration with multiplier `1 - w + w*g'`, `g' = -p_c/(2 dp_inj)`, and it + diverged into a 718 psia / 0 flip-flop on a soft injector. `Session._close_chamber` + brackets the root and solves it. When a coupled quantity oscillates frame to frame, + compute the map's slope before tuning the factor. +- **The cockpit's thermal defaults are on** (vapour, wall-to-liquid 100 film / 3000 + nucleate below a 40 K Leidenfrost superheat, a 2 K boiling-onset superheat, a 1 cm + stratified surface layer, an 8 W/(m²·K) air film in series with the drawing's + `insulation_thickness`/`insulation_conductivity`, wall boiling); the library's are + off. A shut LOX tank climbs at tens of psi a minute because its *surface* warms, not + because the leak boils; a warm one runs away; the pad guide waits for chilldown. The + shipped LOX tank wears an inch of fiberglass (operator). See `docs/PHYSICS-BENCHMARK.md` + 3.8 and 3.11. +- **Every assumed number is a `Setup` field with a row in `backend/tunables.py`.** Do + not add a module constant that describes physics or the stand; add a field, a + `Tunable` with what it accounts for, and the Configuration tab shows it. The + benchmark expectations are stated at the defaults. +- **The console runs the study's numerics.** `LIVE_STEP = 0.02 s`, 120 Newton + iterations, no wall-clock budget: a panel tick is split into study-grid steps, and a + stand too stiff for real time runs in slow motion (the top bar says the ratio). Do not + reintroduce a budget that folds coupling steps -- it made the console integrate a + different scheme from the one `docs/PHYSICS-BENCHMARK.md` checks. Vessels trip the + stand above the MAWP their drawing declares (`Session._check_limits`). See 3.10. +- **Adiabatic is an assumption, not a fact.** Line walls (`feedtwin.comps.wall`) model + the heat a tube and its fittings give the gas during a flow, which is worth ~50 psi + of tank pressure late in a nitrogen burn. What is *not* modelled, on purpose, is + soak: no heat transfer without flow, and no clock on how long a stand has sat. A + wall starts at the temperature of the fluid its line holds at rest, which is where a + soak model would land anyway. + +## Docs worth knowing about + +- `docs/PHYSICS-BENCHMARK.md` — the regression regimen. Start here. +- `docs/overnight/` — a full pipeline run as a user, the defects it found, and the + Phase 14 thermal work. +- `docs/thermal/line-walls.md` — why the icicles on the fittings are evidence for the + line's own thermal mass and against the room, with the arithmetic. +- `docs/integration/` — the cross-app agreements: line-loss method ladder, the + pid-designer handoff, and `daq-k-fitting.md` for the K-fit reader that is meant to + live inside the DAQ. +- `feed-twin/backend/statemachines/NEEDS-REPAIR.md` — 10 malformed rows in the shipped + transition table, why they are not recoverable, and why the machine fails closed. diff --git a/EngineDesign/backend/routers/flight.py b/EngineDesign/backend/routers/flight.py index d9785b83d..744d1129f 100644 --- a/EngineDesign/backend/routers/flight.py +++ b/EngineDesign/backend/routers/flight.py @@ -342,14 +342,8 @@ def build_flight_config(base_config, request: FlightSimRequest): config_dict["environment"]["date"] = request.environment.date config_dict["environment"]["atmosphere_model"] = request.environment.atmosphere_model elif config_dict.get("environment") is None: - # Set defaults - config_dict["environment"] = { - "latitude": 35.0, - "longitude": -117.0, - "elevation": 0.0, - "date": [2025, 1, 1, 12], - "atmosphere_model": "standard_atmosphere", - } + # No launch site anywhere: the request model's defaults, stated once, up in EnvironmentConfig. + config_dict["environment"] = EnvironmentConfig().model_dump() # Update rocket if request.rocket: @@ -379,20 +373,20 @@ def build_flight_config(base_config, request: FlightSimRequest): "fin_position": request.rocket.fins.fin_position, } elif config_dict.get("rocket") is None: - # Set defaults + # No vehicle anywhere: derive from the request model's defaults so this block cannot drift + # from RocketConfig (the literal copy that used to sit here said propulsion_dry_mass 24 kg + # while its own component defaults summed to 16). + rd = RocketConfig() config_dict["rocket"] = { - "airframe_mass": 78.72, - "propulsion_dry_mass": 24.0, - "radius": 0.1015, - "motor_position": 0.0, - "inertia": [8.0, 8.0, 0.5], - "fins": { - "no_fins": 3, - "root_chord": 0.2, - "tip_chord": 0.1, - "fin_span": 0.3, - "fin_position": 0.1, - }, + "airframe_mass": rd.airframe_mass, + "propulsion_dry_mass": rd.engine_mass + rd.lox_tank_structure_mass + rd.fuel_tank_structure_mass, + "radius": rd.radius, + "motor_position": rd.motor_position, + "inertia": list(rd.inertia), + "nose_kind": rd.nose_kind, + "nose_fineness_ratio": rd.nose_fineness_ratio, + "avionics_payload_length_m": rd.avionics_payload_length_m, + "fins": FinsConfig().model_dump(), } return config_dict @@ -424,19 +418,19 @@ def _apply_propellant_mass_caps(config_dict: dict, base_config) -> tuple[dict, d lox_tank_max = lox_max fill_factor = lox_ff current_lox = float(config_dict.get("lox_tank", {}).get("mass", 0) or 0) - effective = min(current_lox, lox_max) if current_lox > lox_max else current_lox + effective = min(current_lox, lox_max) if current_lox > lox_max: config_dict["lox_tank"]["mass"] = lox_max cap_note = "explicit capacity" if lox_explicit else f"{lox_ff * 100:.0f}% fill" print(f"[Flight] Capped LOX mass: {current_lox:.2f} -> {lox_max:.2f} kg ({cap_note}, vol {lox_vol * 1000:.1f}L)") mass_adjustments["lox"] = { "original": current_lox, - "capped": effective if current_lox <= lox_max else lox_max, + "capped": effective, "max_fill_kg": lox_max, "tank_volume_m3": lox_vol, "fill_factor": lox_ff, "was_capped": current_lox > lox_max + 1e-6, - "explicit_capacity_kg": lox_explicit, + "explicit_capacity_kg": lox_max if lox_explicit else None, } if cap_config.fuel_tank is not None: @@ -444,19 +438,19 @@ def _apply_propellant_mass_caps(config_dict: dict, base_config) -> tuple[dict, d fuel_tank_max = fuel_max fill_factor = fuel_ff current_fuel = float(config_dict.get("fuel_tank", {}).get("mass", 0) or 0) - effective = min(current_fuel, fuel_max) if current_fuel > fuel_max else current_fuel + effective = min(current_fuel, fuel_max) if current_fuel > fuel_max: config_dict["fuel_tank"]["mass"] = fuel_max cap_note = "explicit capacity" if fuel_explicit else f"{fuel_ff * 100:.0f}% fill" print(f"[Flight] Capped Fuel mass: {current_fuel:.2f} -> {fuel_max:.2f} kg ({cap_note}, vol {fuel_vol * 1000:.1f}L)") mass_adjustments["fuel"] = { "original": current_fuel, - "capped": effective if current_fuel <= fuel_max else fuel_max, + "capped": effective, "max_fill_kg": fuel_max, "tank_volume_m3": fuel_vol, "fill_factor": fuel_ff, "was_capped": current_fuel > fuel_max + 1e-6, - "explicit_capacity_kg": fuel_explicit, + "explicit_capacity_kg": fuel_max if fuel_explicit else None, } return mass_adjustments, lox_tank_max, fuel_tank_max, fill_factor diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index 70b082a3c..a252b376e 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -28,11 +28,17 @@ feed_system: # under-predicted LOX feed loss by ~3.8x (dP ~ 1/A^2). fuel: line_size: 3/8_NPT + # Tank outlet to injector manifold. Sets the chug-model line inertance (length/area); + # 0.305 m is the value the model used to carry hardcoded -- measure it on the vehicle. + length: 0.305 K0: 2.0 K1: 0.0 phi_type: none oxidizer: line_size: 3/8_NPT + # Tank outlet to injector manifold. Sets the chug-model line inertance (length/area); + # 0.305 m is the value the model used to carry hardcoded -- measure it on the vehicle. + length: 0.305 K0: 2.0 K1: 0.0 phi_type: none @@ -67,7 +73,6 @@ regen_cooling: n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/canonical/pintle.yaml b/EngineDesign/configs/canonical/pintle.yaml index 2189f871d..b144c2425 100644 --- a/EngineDesign/configs/canonical/pintle.yaml +++ b/EngineDesign/configs/canonical/pintle.yaml @@ -33,7 +33,7 @@ chamber_geometry: Cf: 1.5422822280584674 Lstar: 1.239905396733828 chamber_diameter: 0.11344154349849075 - design_MR: 2.55 + design_MR: 1.4 design_pressure: 2413166.0 design_thrust: 7000.0 exit_diameter: 0.101 @@ -173,11 +173,17 @@ feed_system: # under-predicted LOX feed loss by ~3.8x (dP ~ 1/A^2). fuel: line_size: 3/8_NPT + # Tank outlet to injector manifold. Sets the chug-model line inertance (length/area); + # 0.305 m is the value the model used to carry hardcoded -- measure it on the vehicle. + length: 0.305 K0: 2.0 K1: 0.0 phi_type: none oxidizer: line_size: 3/8_NPT + # Tank outlet to injector manifold. Sets the chug-model line inertance (length/area); + # 0.305 m is the value the model used to carry hardcoded -- measure it on the vehicle. + length: 0.305 K0: 2.0 K1: 0.0 phi_type: none @@ -421,7 +427,6 @@ regen_cooling: d_outlet: null enabled: false gas_turbulence_intensity: 0.1 - hot_gas_cp: 2200.0 hot_gas_prandtl: 0.7 hot_gas_thermal_conductivity: 0.12 hot_gas_viscosity: 4.0e-05 diff --git a/EngineDesign/configs/default.yaml b/EngineDesign/configs/default.yaml index 3487be943..8d74feb69 100644 --- a/EngineDesign/configs/default.yaml +++ b/EngineDesign/configs/default.yaml @@ -45,11 +45,17 @@ feed_system: # under-predicted LOX feed loss by ~3.8x (dP ~ 1/A^2). fuel: line_size: 3/8_NPT + # Tank outlet to injector manifold. Sets the chug-model line inertance (length/area); + # 0.305 m is the value the model used to carry hardcoded -- measure it on the vehicle. + length: 0.305 K0: 2.0 K1: 0.0 phi_type: none oxidizer: line_size: 3/8_NPT + # Tank outlet to injector manifold. Sets the chug-model line inertance (length/area); + # 0.305 m is the value the model used to carry hardcoded -- measure it on the vehicle. + length: 0.305 K0: 2.0 K1: 0.0 phi_type: none @@ -84,7 +90,6 @@ regen_cooling: n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/ethalox_doublet_7000N.yaml b/EngineDesign/configs/ethalox_doublet_7000N.yaml new file mode 100644 index 000000000..d08a7249b --- /dev/null +++ b/EngineDesign/configs/ethalox_doublet_7000N.yaml @@ -0,0 +1,639 @@ +ablative_cooling: + ambient_temperature: 300.0 + blowing_coefficient: 0.5 + blowing_efficiency: 0.75 + blowing_min_reduction_factor: 0.1 + char_layer_conductivity: 0.2 + char_layer_thickness: 0.001 + coverage_fraction: 0.9 + enabled: true + heat_of_ablation: 2500000.0 + initial_thickness: 0.0127 + material_density: 1600.0 + nozzle_ablative: false + pyrolysis_temperature: 950.0 + radiative_sink_fallback_temperature: 600.0 + radiative_sink_minimum_threshold: 400.0 + specific_heat: 1500.0 + surface_emissivity: 0.85 + surface_temperature_limit: 1200.0 + thermal_conductivity: 0.35 + throat_recession_multiplier: null + track_geometry_evolution: true + turbulence_exponent: 1.0 + turbulence_max_multiplier: 3.0 + turbulence_reference_intensity: 0.08 + turbulence_sensitivity: 1.5 + use_physics_based_blowing: true +chamber: null +chamber_geometry: + A_exit: 0.009681291607186239 + A_throat: 0.0017353260008265185 + Cf: 1.4263697032912537 + Lstar: 1.1999996608735342 + chamber_diameter: 0.127 + design_MR: 2.55 + design_pressure: 2413166.0 + design_thrust: 7000.0 + exit_diameter: 0.11102523730387681 + expansion_ratio: 5.578946896764721 + length: 0.20337477423185632 + length_contraction: 0.0451 + length_cylindrical: 0.121 + nozzle_efficiency: 0.95 + volume: 0.0020823906124968483 +combustion: + cea: + MR_range: + - 1.0 + - 2.5 + Pc_range: + - 1000000.0 + - 9000000.0 + cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz + cea_parallel_workers: null + eps_range: + - 4.0 + - 15.0 + expansion_ratio: 5.578946896764721 + fuel_name: Ethanol + n_points: 34 + ox_name: LOX + use_parallel_cea_build: false + efficiency: + A0_ethanol: 50000000.0 + A0_hydrocarbon: 10000000.0 + A0_hydrogen: 1000000000.0 + C: 0.3 + Ea_ethanol: 140000.0 + Ea_hydrocarbon: 80000.0 + Ea_hydrogen: 40000.0 + Em_peak: 0.96 + K: 0.15 + Pc_gate: 1000000.0 + R_opt: null + T_star_fuel_cap_K: 500.0 + cooling_efficiency_floor: 0.25 + mixing_model: rupe + mixing_sigma: 1.5 + mixture_efficiency_floor: 0.25 + model: exponential + n_pre_ethanol: 0.25 + n_pre_hydrocarbon: 0.3 + n_pre_hydrogen: 0.2 + n_pressure: 0.8 + smd_penalty_exponent: null + spray_penalty_factor: 0.8 + target_smd_microns: null + target_turbulence_intensity: null + tau_Tc_floor_K: null + tau_ref: 1.0e-05 + tau_ref_P: 4000000.0 + tau_ref_T: 3500.0 + turbulence_efficiency_floor: 0.3 + turbulence_penalty_exponent: null + use_advanced_model: true + use_cooling_coupling: true + use_finite_rate_chemistry: true + use_mixture_coupling: false + use_shifting_equilibrium: true + use_spray_correction: false + use_turbulence_coupling: true + we_penalty_exponent: null + we_reference: null + xstar_limit_mm: null + xstar_penalty_exponent: null +design_requirements: + W_CHAMBER_SHAPE: 2500.0 + W_DP: 800.0 + W_DP_F: 175000.0 + W_DP_HIGH: 25000.0 + W_DP_O: 12000.0 + W_IMPINGING_ANGLE: 400.0 + W_IMPINGING_JET_ASYM: 180.0 + W_IMP_GEOM: 1500.0 + W_MOM: 120000.0 + W_SMD: 0.0 + W_TANK_EQUAL: 30000.0 + W_geom_ao_af_momentum: 3500.0 + acoustic_margin_min: 0.1 + chugging_margin_min: 0.2 + copv_free_volume_L: 4.5 + copv_free_volume_m3: null + feed_pressure_model: dome_regulated + feed_stability_min: 0.15 + frozen_parameters: + A_throat_mm2: null + D_chamber_outer_mm: 165.1 + Lstar_mm: null + P_F_start_psi: null + P_O_start_psi: null + d_jet_F_mm: null + d_jet_O_mm: null + d_orifice_mm: null + d_pintle_tip_mm: null + expansion_ratio: null + h_gap_mm: null + impingement_angle_F_deg: null + impingement_angle_O_deg: null + n_doublets: null + n_orifices: null + spacing_F_mm: null + spacing_O_mm: null + fuel_tank_capacity_kg: null + impinging_momentum_R_max: 1.05 + impinging_momentum_R_min: 0.95 + injector_dp_ratio_F_max: 0.3 + injector_dp_ratio_F_min: 0.2 + injector_dp_ratio_O_max: 0.3 + injector_dp_ratio_O_min: 0.2 + layer1_Lstar_deadband_m: null + layer1_Lstar_from_smd: null + layer1_Lstar_ref_m: null + layer1_Lstar_smd_exponent: null + layer1_Lstar_smd_ref_um: null + layer1_Lstar_target_m: 1.0 + layer1_P_F_start_psi_max: null + layer1_P_F_start_psi_min: null + layer1_P_O_start_psi_max: null + layer1_P_O_start_psi_min: null + layer1_W_EXIT: 1000000.0 + layer1_W_LSTAR: 3000.0 + layer1_W_MASS: null + layer1_W_OF: 60000.0 + layer1_W_OF_high_MR_scale: 1.0 + layer1_W_OF_low_MR_scale: 1.0 + layer1_W_PC: null + layer1_W_THRUST: 60000.0 + layer1_chamber_dt_ratio_max: 3.2 + layer1_chamber_dt_ratio_min: 1.6 + layer1_chamber_ld_ratio_max: 3.2 + layer1_chamber_ld_ratio_min: 1.0 + layer1_chamber_mass_ref_kg: null + layer1_chamber_od_increment_in: 0.5 + layer1_chamber_od_snap_target: null + layer1_chamber_wall_density_kg_m3: null + layer1_cma_restart0_sigma_scale: 0.48 + layer1_cma_warmstart_sigma_frac: 0.04 + layer1_cma_warmstart_trials: 16 + layer1_derive_expansion_ratio: null + layer1_derive_fuel_jet_from_of: null + layer1_derive_impingement_spacing: null + layer1_derive_max_iters: null + layer1_derive_tank_from_dp_ratio: null + layer1_derive_throat_from_thrust: null + layer1_derive_thrust_tol_rel: null + layer1_dp_ratio_target: null + layer1_exit_pressure_deadband_rel: 0.002 + layer1_exit_pressure_inside_quad_scale: 0.38 + layer1_expansion_ratio_max: 14.0 + layer1_expansion_ratio_min: 3.0 + layer1_impingement_Ld_target: null + layer1_impingement_Ld_tol: null + layer1_impinging_angle_deg_max: 90.0 + layer1_impinging_angle_deg_min: 55.0 + layer1_impinging_jet_angle_max_asym_deg: 26.0 + layer1_impinging_n_doublets_max: 30 + layer1_infeasibility_gate_eps: 0.002 + layer1_integer_jet_angles: null + layer1_lbfgs_gtol: 1.0e-09 + layer1_lbfgs_second_pass: true + layer1_lock_tank_pressures: null + layer1_momentum_gate_safe_slack: null + layer1_momentum_log_deadband_rel: null + layer1_momentum_scale: null + layer1_momentum_wall_side_multiplier: null + layer1_of_deadband_rel: 0.02 + layer1_of_validation_tol: null + layer1_random_seed: null + layer1_resultant_tilt_gate_tol_deg: null + layer1_resultant_tilt_max_deg: null + layer1_resultant_tilt_scale_deg: null + layer1_ring_order_fuel_outboard: null + layer1_smd_rel_tol: 0.2 + layer1_stagnation_pressure_frac_max: 1.0 + layer1_stagnation_pressure_frac_min: 0.35 + layer1_tank_equal_inband_frac: null + layer1_tank_equal_scale_psi: 10.0 + layer1_tank_equal_tol_psi: 10.0 + layer1_thrust_deadband_rel: null + layer1_thrust_validation_rel_tol: null + lox_tank_capacity_kg: null + max_Lstar: 1.2 + max_P_tank_F: null + max_P_tank_O: null + max_chamber_outer_diameter: 0.2032 + max_engine_length: 0.4 + max_fuel_tank_pressure_psi: 600.0 + max_lox_tank_pressure_psi: 600.0 + max_nozzle_exit_diameter: 0.2032 + metal_wall_thickness_per_side_m: 0.00635 + min_Lstar: 0.5 + min_stability_margin: 1.05 + min_stability_score: 0.58 + optimal_of_ratio: 1.65 + propellant_tank_fill_factor: 0.9 + require_stable_state: false + stability_margin_handicap: 0.0 + target_apogee: 3048.0 + target_burn_time: 4.2 + target_chamber_pressure_psi: 420.0 + target_smd_microns: 50.0 + target_thrust: 7200.0 +design_valid_for: null +discharge: + fuel: + Cd_inf: 0.6 + Cd_min: 0.35 + P_ref: 5000000.0 + T_ref: 300.0 + a_P: 0.0 + a_Re: 0.18 + a_T: 0.0 + cd_inf_max: 0.62 + cd_inf_min_geom: 0.48 + cd_large_hole_log_gain: 0.015 + cd_small_hole_exponent: 0.2 + d_min_m: 0.0004 + d_ref_m: 0.002 + use_geometry_cd: true + use_pressure_correction: false + use_temperature_correction: false + oxidizer: + Cd_inf: 0.6 + Cd_min: 0.35 + P_ref: 5000000.0 + T_ref: 90.0 + a_P: 0.0 + a_Re: 0.18 + a_T: 0.0 + cd_inf_max: 0.62 + cd_inf_min_geom: 0.48 + cd_large_hole_log_gain: 0.015 + cd_small_hole_exponent: 0.2 + d_min_m: 0.0004 + d_ref_m: 0.002 + use_geometry_cd: true + use_pressure_correction: false + use_temperature_correction: false +environment: + atmosphere_model: standard_atmosphere + date: + - 2026 + - 1 + - 30 + - 18 + elevation: 626.67 + latitude: 35.34722 + longitude: -117.8099547 +feed_system: + fuel: + A_hydraulic: 7.316855998167869e-05 + K0: 0.95 + K1: 0.0 + d_inlet: 0.009652 + line_size: 3/8_NPT + phi_type: none + oxidizer: + A_hydraulic: 0.00012667686977437442 + K0: 0.95 + K1: 0.0 + d_inlet: 0.0127 + line_size: 1/2_NPT + phi_type: none +film_cooling: + apply_to_fraction_of_length: 0.6 + blowing_exponent: 0.62 + cp_override: null + decay_length: 0.05 + density_override: null + effectiveness_ref: 0.45 + enabled: false + injection_temperature: null + mass_fraction: 0.05 + reference_blowing_ratio: 0.6 + reference_wall_temperature: 1100.0 + slot_height: 0.00035 + turbulence_exponent: 1.0 + turbulence_min_multiplier: 0.5 + turbulence_reference_intensity: 0.08 + turbulence_sensitivity: 1.0 +fluids: + fuel: + boiling_point: 351.4 + bulk_modulus_pa: 1060000000.0 + density: 789.0 + latent_heat: 838000.0 + molecular_weight: 46.07 + name: Ethanol + specific_heat: 2440.0 + surface_tension: 0.0223 + temperature: 293.0 + thermal_conductivity: 0.17 + vapor_pressure: 5800.0 + viscosity: 0.0012 + oxidizer: + boiling_point: 90.2 + bulk_modulus_pa: 1500000000.0 + density: 1140.0 + latent_heat: 213000.0 + molecular_weight: 32.0 + name: LOX + specific_heat: 2300.0 + surface_tension: 0.013 + temperature: 90.0 + thermal_conductivity: 0.15 + vapor_pressure: 101325.0 + viscosity: 0.00018 +fuel_tank: + fuel_tank_pos: 3.0 + initial_pressure_psi: 548.6065702551471 + mass: 7.0 + rp1_h: 0.609 + rp1_radius: 0.0762 + tank_volume_m3: 0.011109 +graphite_insert: + ablation_surface_temperature: 3000.0 + ablation_transition_width: 200.0 + activation_energy: 190000.0 + ambient_temperature: 300.0 + axial_half_length: null + axial_half_length_ratio: 0.75 + char_layer_conductivity: 5.0 + char_layer_thickness: 0.0005 + coverage_fraction: 1.0 + emissivity: 0.8 + enabled: true + feedback_fraction_max: 0.2 + feedback_fraction_min: 0.0 + friction_coefficient_override: null + heat_of_ablation: 15000000.0 + initial_thickness: 0.006 + material_density: 2260.0 + mixture_mw: 0.024 + oxidation_enthalpy: 32800000.0 + oxidation_pre_exponential: null + oxidation_pressure_exponent: 0.5 + oxidation_rate: 1.0e-06 + oxidation_reference_pressure: 21000.0 + oxidation_reference_temperature: 973.0 + oxidation_stoichiometry_ratio: 1.0 + oxidation_temperature: 800.0 + oxygen_mass_fraction: 0.05 + oxygen_mole_fraction: null + recession_multiplier: null + reference_diffusivity: null + reference_diffusivity_pressure: 1000000.0 + reference_diffusivity_temperature: 1500.0 + simplified_graphite_oxidation: false + simplified_oxidation_rate: 1.0e-05 + sizing_only_mode: false + sizing_recession_rate: 1.0e-08 + specific_heat: 710.0 + surface_temperature_limit: 2500.0 + thermal_conductivity: 100.0 +injector: + geometry: + fuel: + d_jet: 0.0019180565191778325 + impingement_angle: 56.0 + n_elements: 26 + spacing: 0.008083416949864046 + oxidizer: + d_jet: 0.0022045620057367073 + impingement_angle: 32.0 + n_elements: 26 + spacing: 0.003884252426889093 + type: impinging +lox_tank: + initial_pressure_psi: 548.6065702551471 + lox_h: 1.14 + lox_radius: 0.06985 + mass: 6.75 + ox_tank_pos: 0.8 + tank_volume_m3: 0.017474 +nozzle: null +optimizer: + hybrid: + block_method: corr_greedy + cycles: 3 + elite_k: 50 + lambda0: 0.001 + lambda_max: 1.0 + lambda_mult: 10.0 + lambda_normalize: true + num_blocks: 3 + num_tracks: 1 + overlap_fraction: 0.0 + per_block_budget_fraction: 0.5 + refresh_budget_fraction: 0.1 + refresh_every_pass: true + refresh_sigma_scale: 0.2 + mode: hybrid_cma_blocks +press_tank: + dry_mass: null + free_volume_L: 4.5 + initial_gas_mass: null + mass: null + pres_tank_pos: 3.6 + press_h: 0.457 + press_radius: 0.0762 +pressure_curves: + fuel_segments: + - end_pressure_pa: 3374149.5056212177 + k: 1.0784566785091103 + length_ratio: 0.2286473154180296 + start_pressure_pa: 3610619.76028896 + type: blowdown + - end_pressure_pa: 3164729.7845391277 + k: 1.629427648989163 + length_ratio: 0.20249167104882543 + start_pressure_pa: 3374149.5056212177 + type: blowdown + - end_pressure_pa: 3099133.4332023407 + k: 1.1822268290660432 + length_ratio: 0.06342628444092534 + start_pressure_pa: 3164729.7845391277 + type: blowdown + - end_pressure_pa: 2759036.015920052 + k: 0.9585537699775258 + length_ratio: 0.14732213727033575 + start_pressure_pa: 3099133.4332023407 + type: blowdown + - end_pressure_pa: 2554492.049726525 + k: 1.2370160062004798 + length_ratio: 0.0925191694713507 + start_pressure_pa: 2759036.015920052 + type: blowdown + - end_pressure_pa: 2465667.503851008 + k: 0.8239774831525549 + length_ratio: 0.08588604087308514 + start_pressure_pa: 2554492.049726525 + type: blowdown + - end_pressure_pa: 2393118.9933614894 + k: 0.8847832683519297 + length_ratio: 0.07014845137420163 + start_pressure_pa: 2465667.503851008 + type: blowdown + - end_pressure_pa: 2004754.8014037265 + k: 1.1673408263818559 + length_ratio: 0.10955893010324647 + start_pressure_pa: 2393118.9933614894 + type: blowdown + initial_fuel_pressure_pa: 3610619.76028896 + initial_lox_pressure_pa: 3704289.4239973365 + lox_segments: + - end_pressure_pa: 3467819.1693295944 + k: 0.24164644917319 + length_ratio: 0.2286473154180296 + start_pressure_pa: 3704289.4239973365 + type: blowdown + - end_pressure_pa: 3258399.4482475044 + k: 1.7414629743039511 + length_ratio: 0.20249167104882543 + start_pressure_pa: 3467819.1693295944 + type: blowdown + - end_pressure_pa: 3141806.013426955 + k: 1.3540898135161692 + length_ratio: 0.06342628444092534 + start_pressure_pa: 3258399.4482475044 + type: blowdown + - end_pressure_pa: 2891698.2018464496 + k: 0.7144170122671702 + length_ratio: 0.14732213727033575 + start_pressure_pa: 3141806.013426955 + type: blowdown + - end_pressure_pa: 2796013.581510806 + k: 1.0723820377201234 + length_ratio: 0.0925191694713507 + start_pressure_pa: 2891698.2018464496 + type: blowdown + - end_pressure_pa: 2707189.0356352893 + k: 1.6281617755206732 + length_ratio: 0.08588604087308514 + start_pressure_pa: 2796013.581510806 + type: blowdown + - end_pressure_pa: 2634640.5251457705 + k: 1.5557597269194856 + length_ratio: 0.07014845137420163 + start_pressure_pa: 2707189.0356352893 + type: blowdown + - end_pressure_pa: 2521333.1458079717 + k: 0.948130932276539 + length_ratio: 0.10955893010324647 + start_pressure_pa: 2634640.5251457705 + type: blowdown + n_points: 200 + target_burn_time_s: 4.2 +propellant_preset: ethalox +regen_cooling: + Cd_entrance_inf: 0.8 + Cd_entrance_min: 0.6 + Cd_exit_inf: 0.9 + Cd_exit_min: 0.7 + K_manifold_merge: 0.3 + K_manifold_split: 0.5 + L_inlet: 0.5 + L_outlet: 0.1 + a_Re_entrance: 0.1 + a_Re_exit: 0.1 + chamber_inner_diameter: 0.08491 + channel_height: 0.001 + channel_length: 0.18162 + channel_width: 0.0009 + coolant_turbulence_intensity: 0.05 + d_inlet: 0.009525 + d_outlet: null + enabled: false + gas_turbulence_intensity: 0.1 + hot_gas_cp: 2200.0 + hot_gas_prandtl: 0.7 + hot_gas_thermal_conductivity: 0.12 + hot_gas_viscosity: 4.0e-05 + n_channels: 100 + n_segments: 20 + radiation_emissivity_hot: 0.85 + radiation_view_factor: 1.0 + recovery_factor: null + roughness: 0.0 + use_heat_transfer: true + wall_thermal_conductivity: 320.0 + wall_thickness: 0.002 +rocket: + airframe_mass: 78.72 + avionics_payload_length_m: 4.0 + cm_wo_motor: 3.861725449 + copv_dry_mass: 2.969 + dry_mass: null + engine_cm_offset: 0.15 + engine_mass: 8.0 + fins: + fin_position: 1.054535 + fin_span: 0.20066 + no_fins: 4 + root_chord: 0.626872 + tip_chord: 0.20066 + fuel_tank_structure_mass: 3.0 + inertia: + - 8.0 + - 8.0 + - 0.5 + lox_tank_structure_mass: 5.0 + mass: null + motor: null + motor_inertia: null + motor_position: 0.0 + nose_fineness_ratio: 4.5 + nose_kind: vonKarman + nose_length: null + propulsion_cm_offset: 0.4 + propulsion_dry_mass: 21.0 + radius: 0.078359 + rocket_length: 7.5 +solver: + Pc_bounds: + - 100000.0 + - 8000000.0 + closure: + Cd_reduction_factor: 0.95 + max_iterations: 6 + tolerance: 0.0001 + max_iterations: 100 + method: brentq + tolerance: 1.0e-06 +spray: + evaporation: + C_evap: 1.562 + K: 300000.0 + apply_tau_res_correction: false + cp_gas: 2200.0 + model: derived + use_constraint: true + x_star_limit: 0.05 + momentum_flux_ratio: true + pintle: + B: 2.0 + C: 15.0 + n: 0.5 + p: 0.2 + smd: + C: 0.5 + C_ingebo: 3.9 + chamber_gas_R: 389.0 + chamber_gas_T: 3094.0 + m: 0.6 + model: ingebo + p: 0.0 + we_corr_max: null + spray_angle: + k: 0.5 + model: TMR + n: 0.5 + turbulence_breakup_gain: 1.0 + turbulence_penetration_gain: 0.5 + use_turbulence_corrections: false + weber: + We_min: 15 +stainless_steel_case: null +thrust: + burn_time: 4.2 diff --git a/EngineDesign/configs/impinging_lox_ch4.yaml b/EngineDesign/configs/impinging_lox_ch4.yaml index 7fc8bc47b..1d3e8c37d 100644 --- a/EngineDesign/configs/impinging_lox_ch4.yaml +++ b/EngineDesign/configs/impinging_lox_ch4.yaml @@ -400,7 +400,6 @@ regen_cooling: d_outlet: null enabled: false gas_turbulence_intensity: 0.1 - hot_gas_cp: 2200.0 hot_gas_prandtl: 0.7 hot_gas_thermal_conductivity: 0.12 hot_gas_viscosity: 4.0e-05 diff --git a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml index 37970ae5d..624a2452e 100644 --- a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml +++ b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml @@ -443,7 +443,6 @@ regen_cooling: d_outlet: null enabled: false gas_turbulence_intensity: 0.1 - hot_gas_cp: 2200.0 hot_gas_prandtl: 0.7 hot_gas_thermal_conductivity: 0.12 hot_gas_viscosity: 4.0e-05 diff --git a/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml b/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml index 17d65aa1b..2f5b726cd 100644 --- a/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml +++ b/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml @@ -82,7 +82,6 @@ regen_cooling: n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/impinging_smoke.yaml b/EngineDesign/configs/impinging_smoke.yaml index 4919ae37a..a4217e861 100644 --- a/EngineDesign/configs/impinging_smoke.yaml +++ b/EngineDesign/configs/impinging_smoke.yaml @@ -56,7 +56,6 @@ regen_cooling: n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/test.yaml b/EngineDesign/configs/test.yaml index a8cdd9aeb..dd029b8ec 100644 --- a/EngineDesign/configs/test.yaml +++ b/EngineDesign/configs/test.yaml @@ -76,7 +76,6 @@ regen_cooling: n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/docs/stability/stability_hifi_p0_v1_notes.md b/EngineDesign/docs/stability/stability_hifi_p0_v1_notes.md new file mode 100644 index 000000000..0a4242cac --- /dev/null +++ b/EngineDesign/docs/stability/stability_hifi_p0_v1_notes.md @@ -0,0 +1,401 @@ +# P0/P1 progress notes: eigensolver core + verification cases V1, V2, V3 + contour path + +**Status:** P0 verification ladder complete; P1 begun (real-geometry mesh path done). +V1 (uniform closed-closed cylinder, passive), V2 (temperature-jump duct, passive), and +V3 (n–τ flame duct with choked end, the first genuine NLEVP) are all green — the +paper's P0 exit criterion ("V1–V3 green") is met, with the caveat that the Beyn +completeness audit is P3 scope (see §9). The P1 contour→mesh path (§10) is green. +Package: `engine/stability_hifi/` (new, does not touch `engine/pipeline/stability/`). +Tests: `tests/test_stability_hifi_v1.py` (3 tests), `tests/test_stability_hifi_v2.py` +(3 tests), `tests/test_stability_hifi_v3.py` (6 tests), +`tests/test_stability_hifi_contour.py` (9 tests) — all passing. + +This note walks through the math from the weak form (Eq. 10 of the stability paper) +down to the matrices actually assembled in code, so the derivation is checkable without +reading the source. It also records the review pass on the paper itself. + +--- + +## 1. What V1 checks + +A rigid-walled, uniform-property, closed-closed cylinder has a known closed-form +acoustic spectrum: + +$$ +f_{m,n,k} = \frac{c}{2\pi}\sqrt{\left(\frac{\alpha'_{m,n}}{R_c}\right)^2 + \left(\frac{k\pi}{L}\right)^2} +$$ + +where $\alpha'_{m,n}$ is the $n$-th zero of $J'_m$ (the hard-wall transverse +eigenvalue — same numbers as `engine.pipeline.stability.core.TRANSVERSE_EIGENVALUES`, +here obtained independently from a 2-D FEM solve rather than assumed) and $k$ is the +number of axial half-wavelengths ($k=0$ allowed). V1 is exactly this case with no flame +and no boundary admittance — it validates the mesh, the FEM assembly, the axis-regularity +handling, and the eigensolver, all at once, against a case with no free parameters. + +## 2. From the weak form to three matrices + +Passive, rigid-wall Eq. (10) reduces to + +$$ +\underbrace{\int_\Omega c^2\,\nabla\tilde p\cdot\nabla\bar\phi\; r\,d\Omega}_{K} +\;+\; m^2\underbrace{\int_\Omega \frac{c^2}{r}\,\tilde p\,\bar\phi\;d\Omega}_{K_m} +\;+\;\lambda^2\underbrace{\int_\Omega \tilde p\,\bar\phi\;r\,d\Omega}_{M_2} = 0, +$$ + +i.e. $(K + m^2 K_m)\,p = -\lambda^2 M_2\,p$. Writing $\mu = -\lambda^2$, this is an +ordinary **real-symmetric generalized eigenvalue problem** $A p = \mu M_2 p$ — no +flame delay, no frequency-dependent impedance, so none of the NLEVP machinery (Section +IV) is needed yet; $\mu \ge 0$ always (a lossless rigid cavity can't grow or decay), so +$\lambda$ is purely imaginary and $f = \sqrt{\mu}/2\pi$. + +**Why K and M2 have closed forms but Km doesn't.** On a linear (P1) triangle, the shape +functions $N_i$ are the barycentric coordinates, and $\nabla N_i$ is *constant* over the +element. The radial coordinate $r$ is itself linear in position. So: +- $K$'s integrand is (constant) $\times$ (linear in $r$) — exact via the 1-point + centroid rule, $\int_T r\,dA = \text{Area}\cdot \bar r$. +- $M_2$'s integrand $N_iN_j r$ is cubic (degree 2 from $N_iN_j$, degree 1 from $r$) — + exact via the standard triangle monomial formula + $\int_T L_1^aL_2^bL_3^c\,dA = \frac{a!b!c!}{(a+b+c+2)!}\,2\,\text{Area}$. +- $K_m$'s integrand $N_iN_j/r$ is **not polynomial** (r in the denominator) — no closed + form exists, so it's the only piece evaluated by numerical (6-point Gauss) quadrature. + +This means K and M2 carry *zero* quadrature error — only mesh discretization error — +which is why the convergence-order test below comes out so cleanly. + +**Axis regularity** (Section III.E): for $m=0$ nothing is imposed — the $r\,d\Omega$ +measure weakly enforces the Neumann condition automatically. For $m\ge1$, nodes on +$r=0$ are eliminated (essential Dirichlet $p=0$) before the solve. + +## 3. Eigensolver + +$A = K + m^2K_m$ and $M_2$ are both real, symmetric, positive semi-definite, so this is +solved with `scipy.sparse.linalg.eigsh` in shift-invert mode: one sparse factorization +of $(A - \sigma M_2)$, then symmetric Lanczos on its action. This is the real-symmetric +specialization of the shift-invert Krylov–Schur kernel the paper specifies for the +general (non-symmetric, delay-nonlinear) case — same mechanism, simpler because there's +no delay yet to make the problem nonlinear in $\lambda$. + +## 4. Results + +Cylinder: $c=1000$ m/s, $L=0.3$ m, $R=0.08$ m (arbitrary, chamber-scale). Mesh +100×50 nodes (5000 nodes, well under a second to assemble+solve for both $m=0,1$): + +| mode | analytic (Hz) | FEM (Hz) | rel. error | +|---|---|---|---| +| m=0, axial k=1 | 1666.67 | 1666.74 | 0.0042% | +| m=0, axial k=2 | 3333.33 | 3333.89 | 0.0168% | +| m=0, axial k=3 | 5000.00 | 5001.89 | 0.0378% | +| m=0, radial 1R (α=3.8317), k=0 | 7622.94 | 7624.19 | 0.0164% | +| m=1 (1T), k=0 | 3662.92 | 3663.03 | 0.0031% | +| m=1, k=1 | 4024.27 | 4024.51 | 0.0060% | + +(the 1R row above is a separate spot-check with the shift placed near 7 kHz, not part +of the automated test's lowest-4 comparison — at this geometry's chosen $L,R$ the true +lowest four m=0 modes are all pure-axial harmonics, so the automated test's "lowest 4" +doesn't happen to include a radial mode; the test still exercises the radial branch for +other geometries via `n_radial_max`.) + +All entries above are **well under the paper's 0.1% acceptance bound**. Mesh-refinement +test: halving $h$ (40×20 → 80×40) shrinks the fundamental-mode error by a measured +factor of **~4.2×**, i.e. observed order $\approx \log_2(4.2) \approx 2.07$ — matching +the theoretical $O(h^{2p})$ for $p=1$ (linear) elements exactly as the paper's V1 +acceptance criterion asks for. + +## 5. One implementation note / one honest gap + +- **Elements are linear (P1), not quadratic (P2) as the paper's production tier + specifies.** This is a deliberate P0 simplicity choice (no FEniCSx dependency needed + — see feasibility note below), not an oversight. It still clears the 0.1% bar + comfortably at a 5000-node mesh solved in well under a second, so there's no pressure + to upgrade to P2 for P0; P1/P2 (project phases, not element order — confusing overlap + in terminology, sorry) can revisit if a finer accuracy/cost tradeoff is ever needed. +- **Mode matching pitfall (caught, not shipped):** my first pass at generating analytic + comparison candidates truncated axial index $k$ at a small $k_\max$ *uniformly across + radial branches*. That silently skips real modes (e.g. pure-axial $k=3,4,5$), so the + truncated candidate list stops being the true sorted low end of the spectrum, and it + was matching FEM's true lowest modes against the wrong analytic entries — a 30%+ + "error" that looked like a physics bug but was a test-harness bug. Fixed by generating + a generously over-complete candidate set before truncating to the comparison count. + Flagging this because it's the kind of mismatch that would be easy to miss silently in + a more complex (non-analytic) validation case. + +## 6. Feasibility check (for P1 planning) + +Confirmed via `pip install --dry-run` on this machine (macOS arm64, Python 3.11): + +| package | status | +|---|---| +| `gmsh`, `cantera` | pip wheels available (arm64 macOS) | +| `petsc4py`, `slepc4py` | pip wheels available (resolve to prebuilt `petsc`/`slepc` wheel packages) — better than expected | +| `fenics-dolfinx` | **no pip distribution at all** for macOS — needs conda-forge or a source/Docker build | +| `SU2` | no pip package — manual binary download or source build | + +So P0's scipy-only path needed zero new dependencies (confirmed by this build). P1's +FEniCSx assumption needs revisiting: either accept the hand-rolled assembly style used +here (extended to P2 elements and the true chamber contour), or take on a conda-forge +dependency the rest of the repo doesn't have. + +## 7. Paper review notes (asked-for critical pass) + +- Section II.A's summary of the lumped model was checked line-by-line against + `engine/pipeline/stability/{core,chug,acoustic}.py` — accurate, no misrepresentation. +- References: Bell & Zinn NASA CR-121129 (1973, Georgia Tech) and helmholtz-x + (Ekrem Ekici, University of Cambridge, *Engineering with Computers* 2025) both + verified correct. +- Robin BC (Eq. 8) and the Marble–Candel compact admittance (Appendix C) were + re-derived from scratch — both correct and self-consistent. +- **Real issue found:** the velocity-coupling flame term (Eq. 7b) has its $\lambda$ + cancel against the $1/\lambda$ introduced by $\hat u = -\nabla\hat p/(\lambda\bar\rho)$ + substitution, so the assembled contribution is $\lambda$-independent. The paper's + parenthetical claim that velocity coupling "replaces $b_k$ by the gradient-sampling + functional" (implying a drop-in swap into the same $\lambda F(\lambda)p$ slot) is + imprecise — $F(\lambda)$ itself needs an explicit $1/\lambda$ folded in for that + coupling. Consequence: $N(\lambda)$ has a simple pole at $\lambda=0$ under velocity + coupling, so it's holomorphic only on $\mathbb{C}\setminus\{0\}$, not entire — harmless + for Beyn's method in practice (no acoustic mode of interest sits at the origin) but + worth stating as an explicit caveat rather than an unqualified holomorphy claim. + +--- + +## 8. Verification case V2: temperature-jump duct + +The paper (Section VII.A) names this case ("1-D duct with temperature jump, passive... +checks: nonuniform-c̄ handling") but does not give its analytic solution, so the +reference formula below is an original derivation, not a transcription — worth being +extra careful with, and in fact I got it wrong on the first pass (kept below because +the mistake is as instructive as the fix). + +**Setup.** A duct of length $L=L_1+L_2$, uniform sound speed $c_1$ on $[0,L_1]$ and +$c_2$ on $[L_1,L]$, rigid ("closed") ends at $x=0,L$. Only $\bar c$ is nonuniform — +same rigid BCs and no flame, isolating exactly the one new thing V2 is meant to test. + +**First attempt (wrong).** In each uniform zone, Eq. (6) reduces to $\hat p''=-k_i^2\hat p$ +with $k_i=\omega/c_i$, giving $\hat p_1=A_1\cos(k_1x)$, $\hat p_2=A_2\cos(k_2(x-L))$ (each +already satisfying its end's rigid condition). I assumed the interface matching condition +was continuity of **mass flux** $\bar\rho\hat u$ — the standard rule in general duct +acoustics when gas properties change. Using $\hat u=-\nabla\hat p/(\lambda\bar\rho)$, that +gives $\partial_x\hat p_1=\partial_x\hat p_2$, and eliminating $A_1,A_2$: +$k_1\tan(k_1L_1)+k_2\tan(k_2L_2)=0$. + +**Why it was wrong, and how the error was caught.** This formula passed the obvious +sanity check (uniform limit $c_1=c_2$ recovers V1's $f=nc/2L$ spectrum) but disagreed +with the independently-built FEM solution by several percent — *and that error did not +shrink under mesh refinement*, which (per the V1 convergence test) is the signature of +a wrong reference answer, not discretization error. The mistake: "mass-flux continuity" +assumes a mean flow physically carrying mass across the interface, but Section III.C's +Helmholtz reduction assumes a *quiescent* mean flow ($\bar u\approx0$) — there is no +throughflow here, just still gas with a spatial temperature variation. Going back to +the pair of equations Eq. (6) was combined from (Eq. 4), the energy equation uses the +volumetric dilatation $\nabla\cdot u'$ directly (a kinematic quantity, not a mass flux), +under Appendix B's assumption that $\gamma\bar p$ (not $\bar\rho$) is spatially uniform. +Integrating that equation across a vanishingly thin control volume at the interface +forces **$\hat u$ itself** — not $\bar\rho\hat u$ — to be continuous. Equivalently +(since $\bar c^2=\gamma\bar p/\bar\rho$ with $\gamma\bar p$ the same both sides), +continuity of $\hat u$ is the same statement as continuity of $\bar c^2\partial_x\hat p$ +— which is *also exactly the natural boundary condition the FEM weak form enforces on +its own* at any element edge where $c$ jumps. That the corrected physics argument and +the FEM's automatic behavior agree is a good consistency check in itself. + +**Corrected relation:** $c_1\tan(k_1L_1)+c_2\tan(k_2L_2)=0$ (an extra factor of $c_i$ +relative to the wrong version, since $c_i^2 k_i=c_i\omega$). This is why the uniform- +limit check alone didn't catch the bug: both forms collapse to the same thing when +$c_1=c_2$, since the check can't distinguish which power of $c_i$ belongs in the +formula — it's necessary but not sufficient. Root-found in the pole-free form +$g(\omega)=c_1\sin(k_1L_1)\cos(k_2L_2)+c_2\cos(k_1L_1)\sin(k_2L_2)=0$ (multiplying +through by $\cos(k_1L_1)\cos(k_2L_2)$ removes $\tan$'s poles, which otherwise look like +spurious sign changes to a naive root-finder). + +**Implementation note: representing a genuine discontinuity in FEM.** A real jump in +$c$ can't live on a single shared mesh node (it would need two values at once), so +`assemble_passive` was extended to accept either a per-node field (existing V1 usage — +appropriate for any smoothly-varying mean flow, averaged per element via the P1 +interpolant) or a **per-element** field (new — the material is piecewise-constant per +zone, assigned directly with no averaging). The mesh is built so the interface falls +exactly on a shared node column (`two_zone_duct_mesh`), so no element straddles it and +the per-element assignment is unambiguous. + +**Results** ($c_1=900$, $c_2=1300$ m/s, $L_1=L_2=0.15$ m, $R=0.04$ m, 100×100×50-node +mesh): all four compared modes land under 0.02% error (analytic vs. FEM: 1718.69 vs +1718.71 Hz, 3640.49 vs 3640.67 Hz, 5215.64 vs 5216.14 Hz, 7167.01 vs 7168.43 Hz). +Mesh-refinement test again shows clean $O(h^2)$ convergence, confirming the per-element +material path doesn't degrade accuracy relative to V1's smooth per-node path. + +--- + +## 9. Verification case V3: n–τ flame duct (the first real NLEVP) + +**What's new:** everything that makes the problem the paper's actual subject. V3 is +the first case where $\lambda$ enters *nonlinearly* — through the flame delay +$e^{-\lambda\tau}$ (Eq. 7a/11) — so it is the first exercise of the Section IV solver +hierarchy: Algorithm 1 (frozen-delay fixed point) and the bordered Newton polish +(Section IV.C). It also brings in the boundary-admittance matrix $C$ (Robin condition, +Eq. 8, with the Marble–Candel compact choked-nozzle admittance +$y_{noz}=(\gamma{-}1)\bar M_e/2$ of Appendix C) and the rank-1 flame matrix +$F(\lambda) = \text{gain}\cdot e^{-\lambda\tau}\,\mathbf{g}\mathbf{b}^\top$ of Eq. 11. + +**Scope choices, stated rather than hidden:** (i) the NLEVP path is *dense* +(`numpy`/`scipy.linalg`, companion linearization solved by full `eig`) — the sparse +shift-invert Krylov machinery is already validated on the linear problem by V1/V2, and +V3's job is the delay handling, an orthogonal concern; production-scale meshes will +need the sparse kernel under the same two algorithms. (ii) "All three solvers agree" +in the paper's V3 row is, at P0, *two* solvers: Beyn contour integration is a +completeness audit scheduled for P3 per the paper's own phasing. (iii) The flame is +compact (delta in $x$, uniform in $r$) with one reference point — Eq. 7a lumped to the +simplest structure that still has the full delay nonlinearity. + +**Configuration:** rigid end at $x{=}0$, choked admittance at $x{=}L$, flame sheet at +$x_f = L/3$; discrete form +$N(\lambda)p = [K + \lambda C + \lambda^2 M_2 - \lambda\,\text{gain}\,e^{-\lambda\tau}\mathbf{g}\mathbf{b}^\top]p = 0$, +where $\mathbf{b}$ is a plain point sample of $\hat p$ at the flame reference and +$\mathbf{g}$ is the $r$-weighted disk load $\int N_i\,r\,dr$ at $x_f$. The analytic +reference is a two-zone dispersion relation with the flame as a slope-jump interface +condition, root-found in pole-free product form (V2's lesson applied from the start). + +### Three bugs caught on the way (each instructive) + +1. **Conflating the two flame vectors ($\mathbf{g} = \mathbf{b}$).** Eq. 11's + $\mathbf{g}_k$ (energy-injection weight, carries the $r\,d\Omega$ measure) and + $\mathbf{b}_k$ (dimensionless point sample) have different physical roles. Using + the point sample for both made the flame coupling scale-inconsistent with $K, M_2, + C$ (all $r$-weighted): the coupling strength then depends unphysically on chamber + radius, and even "tiny" gains moved modes by hundreds of Hz. Caught by a smell test + (a nominally small parameter with a huge, non-shrinking effect) and fixed with a + dedicated `disk_load_vector`. The clean signature that the fix is right: the + closed-form 1L sensitivity $d\lambda/d(\text{gain}) = e^{-\lambda_0\tau} + \cos^2(\pi x_f/L)/L$, in which $R$ **cancels exactly** — asserted in the test suite. +2. **Double-counted $R^2/2$ in the analytic reference.** The 1-D reduction's flame + jump is $[\hat p'] = -\lambda\beta e^{-\lambda\tau}\hat p(x_f)$ with + $\beta = \text{gain}/c^2$ — *not* $\text{gain}\cdot(R^2/2)/c^2$. The disk load's + $R^2/2$ is matched by the same factor in the $r$-weighted measure of every other + matrix, so it cancels. With the wrong $\beta$ the reference's flame coupling was + $1/(R^2/2) = 5000\times$ too weak — presenting as "FEM growth rate 5000× larger + than analytic" *while frequencies agreed to 0.003%*. Diagnostic that settled which + side was wrong: the FEM answer was (a) mesh-converged (σ: −85.60 → −85.54 → −85.53 + under refinement — a wrong-discretization error would shrink) and (b) confirmed by + first-order eigenvalue perturbation theory applied directly to the discrete + matrices, an independent third method. Same class of error as V2's interface + condition: the by-hand reference, not the code. +3. **Admittance reflection-coefficient sign in the reference script.** The zone-2 + solution's coefficient is $r_2 = (1-y)/(1+y)$ ($\lambda$-independent for a compact + nozzle); an early script had $(y-s')/(y+s')$ = the negative. Caught by checking the + *passive limit* of the active dispersion relation against the independently + verified $\tanh(sL) = -y$ spectrum ($\sigma \approx -yc/L$, $f \approx nc/2L$). + Rule adopted: always validate the flame-off limit of an active-flame reference + before using it to judge the active code. + + (A fourth, minor one: the first perturbation-theory cross-check itself omitted a + factor of $\lambda_0$ in $\partial N/\partial(\text{gain})$, since the flame enters + $N$ as $-\lambda\,\text{gain}\,e^{-\lambda\tau}\mathbf{g}\mathbf{b}^\top$. With it, + perturbation theory, finite differencing, and the dispersion relation all agree.) + +### A phase-convention finding worth carrying forward + +With the paper's pure-delay pressure coupling (Eq. 7a), a mode's Rayleigh driving goes +as $+\cos(\omega\tau)$ — cycle-averaged $\overline{p'q'} \propto |\hat p|^2\cos(\omega\tau)$, +so in-phase heat release drives and anti-phase damps. Verified numerically across a +$\tau$ sweep (σ = +78.4 at $\omega\tau{=}0.1\pi$, +57.7 at $0.25\pi$, ≈0 at $0.5\pi$, +−57.6 at $0.75\pi$, −80.1 at $0.9\pi$; antisymmetric about $\pi/2$ as $\cos$ demands). +The lumped model's $\sin(\omega\tau)$ driving (`acoustic.mode_driving_rate`) belongs to +the *difference* form $n[p'(t)-p'(t-\tau)]$, whose transfer $n(1-e^{-i\omega\tau})$ has +imaginary part $n\sin(\omega\tau)$. Both are legitimate n–τ closures, but their +instability τ-bands sit a quarter-period apart. **Consequences:** (i) any cross-check +between this framework and the lumped tier must translate conventions first; (ii) the +paper's campaign sanity assertion that "instability τ-bands should straddle +$\tau \approx (2k{+}1)/(2f)$" (Section VI) needs re-examination against whichever +coupling form is in force — for pure-delay Eq. 7a with positive gain, those are the +maximally *damped* bands, not the unstable ones; (iii) this feeds directly into the +open FTF-for-doublets question: whatever flame response is eventually adopted, its +phase convention must be pinned explicitly, because the two standard forms disagree +about *where* in τ the danger zones sit. + +### Results + +At $c{=}1000$ m/s, $L{=}0.3$ m, $x_f{=}0.1$ m, gain $=100$, $\omega\tau\approx\pi$, +choked end ($y_{noz}{=}0.02$), on the (30, 40, 4) development mesh: + +| quantity | value | +|---|---| +| fixed point (9 iters) | $\lambda = -154.0576 + 10473.6405i$ | +| Newton polish (2 iters) | same to 10 digits | +| solver agreement | $5.0\times10^{-10}$ (criterion: $<10^{-6}$) | +| NLEVP residual $\|N(\lambda)p\|/\|p\|$ | $4.8\times10^{-12}$ | +| dispersion relation | $\lambda = -154.0091 + 10472.6906i$ | +| FEM vs analytic | $9.1\times10^{-5}$ (criterion: $<1\%$) | + +Physical decomposition checks out: total σ = −154.1 ≈ nozzle-only damping (−66.7, +itself matching the analytic $-y_{noz}c/L$) plus flame damping at anti-phase (−87.4). +The unstable-mode test (rigid end, $\omega\tau = 0.1\pi$) finds σ = **+78.4**, matching +the dispersion relation to the same accuracy — the tool demonstrably detects +instability, which is its entire purpose. Fixed-point iteration counts (4–9 observed) +sit in the paper's predicted 3–8 range; Newton then converges in ≤2 steps from the +fixed-point answer, exactly the intended division of labor. + +--- + +## 10. P1 step 1: real chamber contour → meridional mesh + +**What and why.** P1 replaces the synthetic rectangles with the true chamber shape. +The wall geometry deliberately *mirrors the hardware construction* in +`engine/core/chamber_geometry*.py` (the code behind the DXF export): cylindrical +section → straight 45° contraction cone → circular entrance arc of radius $1.5R_t$ +tangent to both the cone and the throat (tangency at +$r = R_t[1 + 1.5(1-\cos\theta_c)]$, matching `contraction_length_horizontal_calc` +exactly). New module: `acoustics/contour.py`; checks in +`validation/contour_checks.py` + `tests/test_stability_hifi_contour.py`. + +**Where the acoustic domain ends** (paper Section III.E). Never at the throat — the +mean flow is sonic there and the Helmholtz reduction requires low Mach. The domain is +truncated at a plane in the subsonic chamber and everything downstream is the +Marble–Candel compact admittance $y=(\gamma{-}1)\bar M_e/2$ *at that plane*, with +$\bar M_e$ from the subsonic branch of the isentropic area–Mach relation (implemented +and checked against γ=1.4 compressible-flow tables to 5 decimals). Two supported +conventions, chosen by `truncate_area_ratio`: +- `None` (default): truncate at the convergence-start plane — the classical treatment + and the paper's own words ("nozzle-entrance plane"); the whole convergent section is + part of "the compact nozzle". Lowest Mach at the BC plane (M ≈ 0.1 at CR 6), most + comfortable for the Helmholtz assumptions; slightly overpredicts longitudinal + frequencies since the convergent volume is excluded. +- A ratio in (1, CR): extend into the cone/arc to that area ratio (more accurate mode + volumes; local Mach grows — keep the plane above area ratio ~1.6 (M ≲ 0.4) unless + studying the sensitivity). Both conventions will be compared per-design; V6 later + replaces the compact value with the quasi-1-D admittance ODE. + +**Meshing a mapped domain.** The structured grid maps radially — node $(i,j)$ at +$(x_i,\ r_{wall}(x_i)\cdot j/(n_r{-}1))$ — with the *same* triangulation topology as +P0 (extracted into `mesh._grid_triangles`). Consequences: the axis row is exactly +$r=0$, every column is a constant-$x$ line (so `nodes_at_x(x_end)` finds the +admittance column exactly), segment breaks (cylinder/cone, cone/arc, truncation) are +exact node columns so no element straddles a wall-slope discontinuity, and *nothing* +in assembly/solvers changes. The rigid sloped wall is free: homogeneous Neumann is the +weak form's natural BC — imposed by not adding a boundary term. + +**Verification** (four independent references, none of them the FEM itself): +| check | reference | result | +|---|---|---| +| area–Mach helper | γ=1.4 flow tables (M=0.5→1.33984, M=0.3→2.03507) | exact to 5 decimals | +| degenerate cylinder (default truncation) | V1 analytic formula, m=0 and 1T | <0.05% | +| revolved volume | adaptive quadrature of $\pi r_{wall}^2$; FEM side via $2\pi\sum_{ij}(M_2)_{ij}$ (rows of $M_2$ sum the shape functions to 1) | 3.6e-5 | +| gentle 10° taper modes | Webster horn equation $\frac{d}{dx}(A c^2 \frac{d\hat p}{dx}) + \omega^2 A \hat p = 0$ (the 1-D cross-section-averaged limit of Eq. 6; own model error O(tan²θ)≈3%) | 0.03–0.2% | + +**One honest limitation, documented not hidden.** On the true 45° contour, the +cylinder→cone junction is a *reentrant corner* (fluid-side interior angle 225°): the +eigenfunction gradient has an $r^{\pi/\omega}$ singularity there ($\pi/\omega = 0.8$), +which classical corner theory says caps eigenvalue convergence near O($h^{1.6}$) on +uniform meshes — and the measured Richardson ratios indeed fall below the clean-O($h^2$) +value (compounded by per-segment column allocation not refining perfectly +proportionally on tiny segments). The convergence test therefore asserts monotone +convergence with a successive-difference ratio > 1.5 plus an *absolute* accuracy bound, +rather than pretending order 2. Practical impact is nil at engineering tolerances: the +coarse→fine 1L drift is ~1.5e-4 relative (0.6 Hz out of 3754), orders below +flame-parameter uncertainty. Graded corner meshes are the standard fix if ever needed. + +**Physics sanity observed:** contraction raises the 1L frequency relative to a uniform +cylinder of the same total length (3754 Hz vs 3516 Hz here) — the narrowing end +stiffens the effective duct, consistent with Webster/horn intuition and with both +references agreeing on the gentle-taper case. + +**Next (P1 continues):** the Stage-1 parametric mean-flow generator — burned-fraction +profile ψ(x) with vaporization length from the existing `spalding.py` chain, doublet +ring / pintle radial distributions, CEA-cache thermodynamics — producing a +`MeanFlowSpec` on these meshes; then the campaign layer (m-loop, Σ-sweep, margins, +report) per Algorithm 2. diff --git a/EngineDesign/docs/stability/thermoacoustic_global_stability_paper.md b/EngineDesign/docs/stability/thermoacoustic_global_stability_paper.md new file mode 100644 index 000000000..7c465176b --- /dev/null +++ b/EngineDesign/docs/stability/thermoacoustic_global_stability_paper.md @@ -0,0 +1,632 @@ +# High-Fidelity Combustion Stability Analysis of Liquid Rocket Engines via Thermoacoustic Global Modes and Krylov Eigensolvers: Formulation and Preliminary Implementation Plan + +**EngineDesign Combustion Stability Suite — Design Document / Preliminary Paper (v0.1)** + +*Prepared July 2026. Status: formulation and implementation plan; no computational results yet. This document specifies the "rich acoustic model" (plan §A3) anticipated by `docs/stability/combustion_stability_physics.md` [35] and by the lumped-model implementation in `engine/pipeline/stability/` (`core.py`, `chug.py`, `acoustic.py`): it computes from first principles the quantities that model must assume — mode frequencies in the true geometry, mode-shape/heat-release overlap, and the acoustic damping budget.* + +--- + +## Abstract + +Combustion instability — the resonant coupling of unsteady heat release with the acoustic modes of a combustion chamber — remains the highest-consequence failure mode in liquid rocket engine development. The EngineDesign suite currently carries a documented lumped-parameter stability model [35]: Crocco $n$–$\tau$ modal driving with vaporization-derived time lags, an itemized acoustic damping budget, and an impedance-form chug characteristic equation. That model is physically sound but structurally zero-dimensional: mode frequencies come from uniform-cylinder closed forms, the mode-shape/heat-release overlap is a per-mode assumed scalar, and the damping coefficients are flagged first-cut fractions. This document develops the spatially-resolved tier that computes those assumed quantities from first principles. We formulate combustion stability as a **global linear stability problem**: the compressible reacting flow equations are linearized about a computed mean flow, a modal ansatz $q'(\mathbf{x},t) = \hat{q}(\mathbf{x})\,e^{\lambda t}$ reduces the dynamics to an eigenvalue problem, and the complex eigenvalue $\lambda = \sigma + i\omega$ delivers the growth rate ($\sigma > 0 \Rightarrow$ unstable) and frequency of every relevant chamber mode. Rather than attack the full linearized Navier–Stokes operator at once, we exploit the low-Mach character of the chamber to reduce the perturbation dynamics to an **inhomogeneous thermoacoustic Helmholtz equation** with an active flame closure, discretized by finite elements on the true chamber geometry. Because the mean flow of an axisymmetric thrust chamber is (to engineering accuracy) axisymmetric, the eigenmodes separate as $\hat{p}(x,r,\theta) = \tilde{p}(x,r)\,e^{im\theta}$, converting one intractable 3-D eigenproblem into a family of small 2-D problems indexed by azimuthal wavenumber $m$ — giving longitudinal ($m=0$), tangential ($m=1,2,\dots$), and radial mode families at two-dimensional cost. Flame–acoustic coupling through time-delayed response models and frequency-dependent boundary impedances renders the eigenproblem **nonlinear in $\lambda$**; we present a solver hierarchy (frozen-delay fixed point, bordered Newton, Beyn contour integration, SLEPc NLEIGS) built around a shift-invert Krylov–Schur kernel. Mean flows are produced by a two-stage pipeline: a parametric generator (CEA/Cantera equilibrium chemistry + vaporization-limited heat-release correlations, with injector-specific flame-shape models for unlike-impinging doublets and pintles, and propellant support for LOX/CH₄, LOX/ethanol, and LOX/RP-1) that warm-starts an axisymmetric SU2 RANS solve. Because the flame response parameters are the dominant epistemic uncertainty, the deliverable of an analysis campaign is not a single eigenvalue but a **stability map**: growth-rate contours over the flame-parameter envelope for each mode family, with margins defined as distance-to-neutral-curve. A verification ladder (analytic cylinder modes, temperature-jump ducts, Rijke tube benchmarks, cross-code comparison, time-domain growth-rate extraction) and a validation plan against the Purdue CVRC and DLR BKD experiments are specified. The framework is designed as a post-design verification tool: one campaign per candidate design, roughly one hour end-to-end on a workstation, with no fidelity compromise driven by optimizer-loop cost. + +--- + +## I. Nomenclature + +| Symbol | Meaning | +|---|---| +| $\mathbf{w}$ | flow state vector (conservative or primitive variables) | +| $\bar{q},\ \bar{\rho},\ \bar{p},\ \bar{T},\ \bar{\mathbf{u}}$ | mean (base) state: generic, density, pressure, temperature, velocity | +| $q'(\mathbf{x},t)$ | infinitesimal perturbation about the mean state | +| $\hat{q}(\mathbf{x})$ | complex mode shape (spatial eigenfunction) | +| $\lambda = \sigma + i\omega$ | complex eigenvalue; $\sigma$ growth rate [1/s], $\omega$ angular frequency [rad/s] | +| $f = \omega/2\pi$ | mode frequency [Hz] | +| $\mathbf{r}(\mathbf{w};\mathbf{x}_d)$ | bottom-level (mean flow) residual; $\mathbf{x}_d$ design variables | +| $J$ | Jacobian of the discretized residual, $\partial \mathbf{r}/\partial \mathbf{w}\big|_{\bar{\mathbf{w}}}$ | +| $B,\ M$ | mass matrix of the discretization | +| $c(\mathbf{x})$ | local sound speed, $\sqrt{\gamma R T}$ | +| $\gamma,\ R$ | ratio of specific heats, specific gas constant | +| $\dot{q}',\ \hat{\dot{q}}$ | heat-release-rate perturbation and its mode shape [W/m³] | +| $\bar{\dot{q}}(\mathbf{x})$ | mean volumetric heat release [W/m³] | +| $n,\ \tau$ | flame interaction index and time lag (Crocco $n$–$\tau$ model) | +| $z(\lambda)$ | specific acoustic impedance, $\hat{p}/(\bar{\rho}\bar{c}\,\hat{\mathbf{u}}\cdot\mathbf{n})$ | +| $y = 1/z$ | specific admittance | +| $m$ | azimuthal wavenumber; modes vary as $e^{im\theta}$ | +| $\tilde{p}(x,r)$ | meridional (2-D) pressure mode shape | +| $K,\ K_m,\ C,\ M_2,\ F$ | FEM stiffness, azimuthal, boundary-damping, mass, and flame matrices | +| $N(\lambda)$ | nonlinear eigenvalue operator, $N(\lambda)\tilde{p} = 0$ | +| $L^*$ | characteristic chamber length, $V_c/A_t$ | +| $P_c,\ \mathrm{MR}$ | chamber pressure, oxidizer-to-fuel mixture ratio | +| $\psi(x)$ | burned (energy-released) fraction profile along the chamber axis | +| $L_v$ | vaporization/consumption length | +| $d_{32}$ | Sauter mean droplet diameter | +| $E_m$ | Rupe mixing-uniformity factor | +| $\mathcal{Z}$ | mixture fraction (flamelet coordinate) | +| $\Sigma$ | flame-parameter envelope in $(n,\tau)$ space | + +Acronyms: LRE (liquid rocket engine), LNSE (linearized Navier–Stokes equations), LEE (linearized Euler equations), NLEVP (nonlinear eigenvalue problem), GEVP (generalized eigenvalue problem), FTF/FDF (flame transfer/describing function), FEM (finite element method), RANS (Reynolds-averaged Navier–Stokes), FGM (flamelet-generated manifold), IRAM (implicitly restarted Arnoldi method), CVRC (Continuously Variable Resonance Combustor), MOC (method of characteristics). + +Convention: perturbations evolve as $e^{\lambda t}$ with $\lambda = \sigma + i\omega$. Literature using $e^{-i\omega_c t}$ with complex $\omega_c$ maps as $\omega_c = i\lambda$, i.e., $\mathrm{Im}(\omega_c) = \sigma$. All eigenvalues are reported dimensionally ([1/s], [Hz]) and nondimensionally ($\lambda^* = \lambda L_{\mathrm{ref}}/c_{\mathrm{ref}}$); see Appendix D. + +--- + +## II. Introduction + +### A. Motivation and the current state of the suite + +High-frequency combustion instability has destroyed more liquid rocket engine development programs than any other single phenomenon; the F-1 program required over 2,000 full-scale tests to stabilize the injector against the first tangential (1T) mode [1, 2]. The mechanism is classical: unsteady heat release $\dot{q}'$ adds energy to an acoustic field wherever it is positively correlated with the pressure fluctuation — Rayleigh's criterion, + +$$ +\frac{dE_{ac}}{dt} \;\propto\; \int_V \overline{p'(\mathbf{x},t)\,\dot{q}'(\mathbf{x},t)}\; dV \;-\; \text{(boundary and volumetric losses)}, +$$ + +and instability results when the flame-driven gain of any chamber mode exceeds its damping. Prediction therefore requires three ingredients simultaneously: (i) the chamber's acoustic eigenstructure in its true geometry with its true (nonuniform) sound-speed field, (ii) a model for how the combustion process responds to acoustic fluctuations, and (iii) the damping supplied by the nozzle, injector face, and walls. + +The stability module currently shipped in EngineDesign (`engine/pipeline/stability/`: `core.py`, `chug.py`, `acoustic.py`) supplies all three ingredients in **lumped** form, with its assumptions documented in the companion physics reference [35]: (i) acoustic frequencies from uniform-property cylinder closed forms (quarter-wave longitudinal set; hard-wall $J'_m$ transverse eigenvalues); (ii) combustion response via the Crocco $n$–$\tau$ gain with the sensitive lag $\tau_{sens} = \chi\,\tau_{vap}$ built from the spray SMD through the Spalding $d^2$-law; (iii) damping as an itemized first-cut budget (nozzle, viscous, injector, two-phase). Per-mode growth is then $\alpha = \alpha_{drive} - \alpha_{damp}$ with $\alpha_{drive} = \tfrac{\omega}{2}(\gamma{-}1)\,\Lambda\, n \sin(\omega\tau_{sens})$, where $\Lambda$ is a mode-shape/heat-release **overlap factor taken from a documented lookup table** (`DEFAULT_OVERLAP`), and the chug loop is closed properly through an impedance-form characteristic equation solved by Nyquist margin and complex root-find. + +This is a defensible level-1/level-2 design-guidance model (Section II.B taxonomy), and its own documentation identifies precisely what it cannot do [35, §4.2, §8]: the overlap factors $\Lambda$, the damping fractions, and the uniform-cylinder frequencies are *assumed inputs*, uncertain to factors of several, and no spatial mode information exists at all — no mode shapes, no Rayleigh-integrand localization, no geometry or temperature-stratification effects, no impedance boundary conditions. The present document specifies the planned rich tier (plan §A3 of [35]) that replaces exactly those assumptions with computed quantities: eigenfrequencies and mode shapes on the true chamber contour with the true sound-speed field, the overlap emerging as the computed flame-matrix/eigenfunction inner product rather than a table entry, and the damping budget emerging as boundary-flux integrals under physical impedance conditions. The lumped chug model is *retained* as the authoritative feed-coupling analysis (its lags and primitives also seed this framework's flame-parameter envelope, Section VI), and the lumped acoustic tier remains the in-loop screen. + +### B. Approaches to instability prediction + +Four families of methods exist, in increasing fidelity and cost: + +1. **Empirical margins and similarity rules**: frequency spacing checks, $L^*$ ranges, injector $\Delta p/P_c$ rules of thumb [1]. Cheap, but they cannot distinguish a stable design from a marginally unstable one, and carry no mode-shape or growth-rate information — precisely the criticism leveled at empirical buffet-onset criteria by Kanchi et al. [3], whose linear-stability formulation for transonic buffet this document deliberately parallels. +2. **Low-order lumped and network (two-port) models** (the current module's class): the chamber, injector, and feed system are represented as lumped elements or transfer matrices; the suite's chug characteristic equation [35, §3.2] is of this family, as are OSCILOS [4] and taX [5]. Excellent for chug and useful for longitudinal modes; for transverse modes in a real chamber they must *assume* the mode structure (the overlap-factor table of [35, §4.2]) rather than compute it. +3. **Inhomogeneous Helmholtz / linearized Euler eigenproblems on the true geometry** with an active-flame closure: the approach of AVSP at CERFACS [6], subsequent FEM implementations [7, 8], and LEE extensions applied to rocket combustors by Schulze and Sattelmayer [9]. This resolves 3-D mode structure in the real geometry with nonuniform properties, distributed flame response, and impedance boundary conditions, at a cost measured in minutes. +4. **Full linearized Navier–Stokes global modes** about a RANS/LES mean flow [10, 11], and ultimately nonlinear LES of the instability itself [12]. Research-grade; mean-flow generation dominates the cost. + +The framework specified here enters at level 3, with the interfaces designed so that level-4 operators (LEE, then LNSE) can replace the Helmholtz operator without changing the eigensolver machinery, the mean-flow pipeline, or the campaign/reporting layer. This mirrors the two-level structure of [3]: a *bottom-level* nonlinear problem defines the mean state; a *top-level* eigenvalue problem, whose operator is the Jacobian of the bottom level (or a physics-reduced surrogate of it), defines stability. + +### C. Scope, requirements, and contributions + +Requirements established for the tool: + +- **R1 — Post-design verification, not in-loop screening.** One analysis campaign per candidate design; wall-clock budget ~1 hour on a workstation; fidelity is not to be compromised for speed. +- **R2 — Propellant coverage:** LOX/CH₄ (primary), LOX/ethanol, LOX/RP-1, through a chemistry interface (CEA/Cantera) that is propellant-agnostic. +- **R3 — Injector coverage:** unlike-impinging doublets (primary) and pintles, entering the formulation only through the mean heat-release distribution and the flame-response parameterization. +- **R4 — Mode coverage:** longitudinal, tangential (standing and spinning), radial, and mixed modes up to at least the 3T/2L family; feed-coupled (chug) modes retained by the existing low-frequency module and, later, by network-element boundary conditions. +- **R5 — Honest uncertainty treatment:** flame-response parameters are swept over an envelope; the deliverable is a stability map with margins, not a point verdict. +- **R6 — Non-invasive integration:** delivered as a new package (`engine/stability_hifi/`), leaving the existing module untouched. + +The contributions of this document: (i) a complete mathematical formulation from the reacting LNSE down to the discrete NLEVP, with every reduction and its validity conditions stated; (ii) an azimuthal-decomposition formulation that obtains 3-D transverse-mode fidelity at 2-D cost; (iii) a solver hierarchy for the NLEVP with algorithms specified to pseudocode; (iv) a two-stage mean-flow pipeline (parametric generator → warm-started SU2 axisymmetric RANS) covering R2/R3; (v) a verification ladder and validation plan with acceptance criteria; (vi) cost estimates and a tiered roadmap to LEE, FDF/limit-cycle, resolvent, and adjoint-sensitivity extensions. + +--- + +## III. Linear stability formulation + +### A. Bottom level: the mean flow problem + +Following the notation of [3], the semi-discretized compressible reacting flow equations are + +$$ +B\,\frac{\partial \mathbf{w}}{\partial t} + \mathbf{r}(\mathbf{w};\mathbf{x}_d) = 0, +$$ + +where $\mathbf{w} \in \mathbb{R}^{N}$ collects density, momentum, energy, and species (or progress-variable) unknowns, $B$ is the mass matrix of the discretization, and $\mathbf{x}_d$ are design parameters (geometry, $P_c$, MR, injector pattern). The **mean flow** $\bar{\mathbf{w}}$ is an equilibrium of the *modeled* (Reynolds-averaged) system: + +$$ +\mathbf{r}(\bar{\mathbf{w}};\mathbf{x}_d) = 0. \tag{1} +$$ + +Two remarks specific to rocket chambers. First, no steady laminar solution exists; Eq. (1) is meaningful only for the RANS-closed system, and the linearization below inherits the closure (we adopt the *frozen eddy viscosity* assumption standard in mean-flow global stability [10, 13]: turbulent transport coefficients are evaluated at the mean state and not perturbed). Second, for the Helmholtz-level analysis of Section III.C, only scalar fields of Eq. (1)'s solution are consumed — $\bar{\rho}(\mathbf{x})$, $\bar{c}(\mathbf{x})$, $\gamma(\mathbf{x})$, $\bar{\dot{q}}(\mathbf{x})$ — so the bottom level may be satisfied approximately by the parametric generator of Section V.B during development, and by SU2 RANS (Section V.C) in production. + +### B. Top level: linearized dynamics and the modal ansatz + +Perturbing $\mathbf{w} = \bar{\mathbf{w}} + \mathbf{w}'$ with $\|\mathbf{w}'\| \ll \|\bar{\mathbf{w}}\|$ and retaining first order, + +$$ +B\,\frac{\partial \mathbf{w}'}{\partial t} = -J\,\mathbf{w}', \qquad J \equiv \frac{\partial \mathbf{r}}{\partial \mathbf{w}}\bigg|_{\bar{\mathbf{w}}}. \tag{2} +$$ + +The modal ansatz $\mathbf{w}'(t) = \hat{\mathbf{q}}\,e^{\lambda t}$ yields the **generalized eigenvalue problem** + +$$ +J\,\hat{\mathbf{q}} = -\lambda\, B\,\hat{\mathbf{q}}, \tag{3} +$$ + +with $\lambda = \sigma + i\omega$. The equilibrium is linearly stable iff every eigenvalue satisfies $\sigma < 0$; the design-relevant output is the set of eigenvalues nearest the imaginary axis in the frequency band of the chamber's low-order acoustic modes, together with their mode shapes $\hat{\mathbf{q}}$ (which identify the mode family and localize the driving via the Rayleigh integrand $\mathrm{Re}[\hat{p}^*\hat{\dot{q}}]$). + +Three structural facts drive the entire numerical design, exactly as in [3]: + +1. $N$ is large (10⁵–10⁸ depending on tier), so $O(N^3)$ dense methods (QR/QZ) are excluded; Krylov projection onto a small upper-Hessenberg matrix (Arnoldi and its implicitly-restarted and Krylov–Schur variants [14, 15]) is mandatory. +2. The eigenvalues of engineering interest are **not** extremal in modulus — the spectrum's largest-modulus members are heavily damped fine-grid acoustic/diffusive modes — so a **spectral transformation** (shift-invert, Section IV.E) or a time-stepper/Cayley propagator [3, 16] is required to make them extremal. +3. $J$ is strongly **non-normal**: eigenvalues govern only asymptotic behavior, and finite-amplitude "triggering" of linearly stable states — well documented in both rocket history [1] and modern thermoacoustics [17] — is a transient-growth/nonlinear phenomenon. The linear tool is therefore necessary but not sufficient; resolvent analysis over the same operator is a planned extension (Section X), and linear stability verdicts are reported alongside this caveat. + +### C. Reduction to the thermoacoustic Helmholtz equation + +The full reacting LNSE requires a field mean flow and a resolved-flame linearization of stiff chemistry — both beyond present scope (and, at RANS resolution, of questionable meaning for the chemistry source terms). The classical and well-validated reduction [6, 18] exploits the rocket-chamber ordering: chamber Mach number $\bar{M} \lesssim 0.2$–$0.3$, acoustic wavelengths comparable to chamber dimensions, mean pressure nearly uniform. Linearizing the Euler equations about a quiescent-to-leading-order mean state ($\bar{\mathbf{u}} \approx 0$, $\bar{p} \approx$ const, $\bar{\rho}(\mathbf{x})$, $\bar{T}(\mathbf{x})$ nonuniform) with a heat-release source: + +$$ +\bar{\rho}\,\frac{\partial \mathbf{u}'}{\partial t} = -\nabla p', \qquad +\frac{\partial p'}{\partial t} + \gamma \bar{p}\,\nabla\!\cdot\!\mathbf{u}' = (\gamma - 1)\,\dot{q}'. \tag{4} +$$ + +Eliminating $\mathbf{u}'$ (take $\partial_t$ of the second equation, insert the first, use $\bar{c}^2 = \gamma\bar{p}/\bar{\rho}$ with $\gamma\bar{p}$ spatially constant): + +$$ +\frac{\partial^2 p'}{\partial t^2} \;-\; \nabla\!\cdot\!\left(\bar{c}^2(\mathbf{x})\,\nabla p'\right) \;=\; (\gamma-1)\,\frac{\partial \dot{q}'}{\partial t}. \tag{5} +$$ + +With $p' = \hat{p}(\mathbf{x})\,e^{\lambda t}$, $\dot{q}' = \hat{\dot{q}}(\mathbf{x})\,e^{\lambda t}$: + +$$ +\boxed{\;\lambda^2\,\hat{p} \;-\; \nabla\!\cdot\!\left(\bar{c}^2\,\nabla\hat{p}\right) \;=\; (\gamma-1)\,\lambda\,\hat{\dot{q}}.\;} \tag{6} +$$ + +Velocity mode shapes are recovered from the linearized momentum equation, $\hat{\mathbf{u}} = -\nabla\hat{p}/(\lambda\bar{\rho})$. + +**Validity and consequences.** The neglected mean-flow terms are $O(\bar{M})$; their leading physical effect is convective damping, chiefly through the nozzle, which is reinstated through the boundary admittance (Section III.E). Entropy-wave/nozzle interaction (indirect noise driving of longitudinal modes) is likewise absent at this tier and is the principal physics motivating the LEE upgrade (Section X). Within these limits, Eq. (6) with a distributed active flame is the workhorse of industrial thermoacoustics [6, 7, 8] and captures the geometry, temperature-stratification, and flame-placement physics entirely missing from the current module. + +### D. Flame response closure + +Equation (6) is unclosed until $\hat{\dot{q}}$ is expressed in terms of the acoustic field. We adopt the time-delayed response family, in both classic couplings: + +**Pressure coupling** (Crocco's pressure interaction index [19]; the natural first model for LRE injector-plane-dominated response): + +$$ +\hat{\dot{q}}(\mathbf{x}) \;=\; n_p(\mathbf{x})\;\frac{\bar{\dot{q}}(\mathbf{x})}{\bar{p}}\;e^{-\lambda\tau(\mathbf{x})}\;\hat{p}(\mathbf{x}_{\mathrm{ref}}). \tag{7a} +$$ + +**Velocity coupling** (the AVSP form [6], appropriate where the response is controlled by injection-velocity or mixing fluctuation): + +$$ +\hat{\dot{q}}(\mathbf{x}) \;=\; n_u(\mathbf{x})\;e^{-\lambda\tau(\mathbf{x})}\;\hat{\mathbf{u}}(\mathbf{x}_{\mathrm{ref}})\!\cdot\!\mathbf{n}_{\mathrm{ref}} +\;=\; -\,\frac{n_u(\mathbf{x})}{\lambda\,\bar{\rho}(\mathbf{x}_{\mathrm{ref}})}\;e^{-\lambda\tau(\mathbf{x})}\;\nabla\hat{p}\big|_{\mathbf{x}_{\mathrm{ref}}}\!\cdot\!\mathbf{n}_{\mathrm{ref}}. \tag{7b} +$$ + +Here $n_p, n_u \geq 0$ are interaction indices, $\tau(\mathbf{x})$ is the local time lag, and $\mathbf{x}_{\mathrm{ref}}$ a reference location (injection plane, per-element or per-ring; the implementation supports one reference per injector ring so that transverse modes sample the response at the correct radius and phase). The spatial weights are normalized so that $n$ retains its classical global meaning: + +$$ +\int_V n_{(\cdot)}(\mathbf{x})\, w(\mathbf{x})\,dV = n \int_V w(\mathbf{x})\,dV, +\qquad w(\mathbf{x}) = \bar{\dot{q}}(\mathbf{x}) \Big/ \int_V \bar{\dot{q}}\,dV, +$$ + +with $w(\mathbf{x})$ supplied by the mean-flow pipeline — this is precisely where the doublet-ring versus pintle-cone distinction, and the propellant-dependent vaporization length, enter the eigenproblem. + +Two consequences. First, substituting (7) into (6) couples $\hat{p}$ at $\mathbf{x}$ to $\hat{p}$ (or $\nabla\hat{p}$) at $\mathbf{x}_{\mathrm{ref}}$ — the flame term is a **nonlocal rank-structured operator**, not a pointwise coefficient. Second, and centrally, the factor $e^{-\lambda\tau}$ makes the eigenproblem **nonlinear in $\lambda$**. Section IV is organized entirely around this fact. + +The amplitude-dependent generalization — the flame *describing* function $n(\omega, |\hat{u}|)$, $\tau(\omega, |\hat{u}|)$ [20] — is deliberately deferred: within the framework it replaces the constant-parameter flame matrix by an amplitude-parameterized one, and the eigenvalue solve by a harmonic-balance fixed point over amplitude, predicting limit-cycle levels rather than only onset. Nothing upstream (mesh, mean flow, matrices, solvers) changes; see Section X. + +**Where the parameters come from.** $\tau$ estimates follow from the physics that sets the lag, and the suite already computes them: the existing `core.lags_from_smd` chain (Ingebo SMD $\to$ Spalding $B_T$ $\to$ $d^2$-law $\to$ $\tau_{vap}$, $\tau_{sens} = \chi\,\tau_{vap}$) [35, §5] supplies the vaporization-controlled estimate for liquid–liquid doublets; for gas–gas methane elements, mixing/convection times from element exit to flame anchoring replace it. These estimates seed the *center* of the swept envelope $\Sigma$ (Section VI), with the sensitive fraction $\chi$ — identified in [35] as the single largest modeling uncertainty — spanned by the sweep rather than fixed; they are inputs to be swept, not trusted constants. + +### E. Boundary conditions + +On each boundary segment the linearized momentum equation converts an impedance statement into a Robin condition. With outward normal $\mathbf{n}$ and specific impedance $z(\lambda) = \hat{p}/(\bar{\rho}\,\bar{c}\;\hat{\mathbf{u}}\cdot\mathbf{n})$: + +$$ +\nabla\hat{p}\cdot\mathbf{n} \;=\; -\,\lambda\,\bar{\rho}\;\hat{\mathbf{u}}\cdot\mathbf{n} \;=\; -\,\frac{\lambda}{\bar{c}\,z(\lambda)}\;\hat{p}. \tag{8} +$$ + +- **Rigid wall** ($z \to \infty$): homogeneous Neumann, $\nabla\hat{p}\cdot\mathbf{n} = 0$. Default for chamber walls. +- **Choked nozzle**: the compact (short-nozzle) admittance of Marble and Candel [21] applied at the nozzle-entrance plane, +$$ +y_{\mathrm{noz}} = \frac{1}{z} = \frac{\gamma - 1}{2}\,\bar{M}_e \;+\; O(\lambda\,\ell_{\mathrm{noz}}/\bar{c}), +$$ +with $\bar{M}_e$ the entrance Mach number. This is the leading acoustic damping mechanism of the chamber and must be present for growth rates to be meaningful. For nozzles that are not acoustically compact at tangential-mode frequencies, the admittance is upgraded to the frequency-dependent solution of the quasi-1-D nozzle admittance ODE (Crocco–Sirignano / Bell–Zinn class [22]), integrated numerically per $\lambda$ — one more source of $\lambda$-nonlinearity, handled identically to the flame delay. +- **Injector face**: rigid by default; per-element or per-ring impedance $z_{\mathrm{inj}}(\lambda)$ from a feed/element transfer function when injector-coupled (intermediate-frequency) modes are of interest. This is the future hook for feed-system network coupling (R4). +- **Acoustic absorbers** (quarter-wave cavities, baffle damping): lumped $z(\lambda)$ patches on the boundary — the natural mechanism by which damping-device sizing enters the same analysis. + +Axis regularity (meridional formulation): $\partial_r \tilde{p} = 0$ at $r=0$ for $m=0$; $\tilde{p}(r{=}0) = 0$ for $m \geq 1$. + +### F. Azimuthal decomposition: 3-D modes at 2-D cost + +For an axisymmetric mean state — exact for a single-element pintle, and correct to leading order for ring-pattern doublet faces once element-scale granularity is smeared azimuthally (valid because acoustic wavelengths $\sim D_c$ vastly exceed element spacing) — the operator in Eq. (6) is invariant under rotation, and eigenmodes separate: + +$$ +\hat{p}(x, r, \theta) = \tilde{p}(x, r)\,e^{im\theta}, \qquad m \in \mathbb{Z}_{\geq 0}. +$$ + +In cylindrical coordinates, + +$$ +\nabla\!\cdot\!(\bar{c}^2 \nabla \hat{p}) \;=\; +\left[\frac{\partial}{\partial x}\!\left(\bar{c}^2 \frac{\partial \tilde{p}}{\partial x}\right) ++ \frac{1}{r}\frac{\partial}{\partial r}\!\left(r\,\bar{c}^2 \frac{\partial \tilde{p}}{\partial r}\right) +- \frac{m^2 \bar{c}^2}{r^2}\,\tilde{p}\right] e^{im\theta}, +$$ + +so each $m$ yields an independent 2-D eigenproblem on the meridional half-plane $\Omega$ (the revolved chamber contour — directly consumable from the existing `chamber_geometry` contour): + +$$ +\lambda^2 \tilde{p} +\;-\; \frac{\partial}{\partial x}\!\left(\bar{c}^2 \frac{\partial \tilde{p}}{\partial x}\right) +\;-\; \frac{1}{r}\frac{\partial}{\partial r}\!\left(r\,\bar{c}^2 \frac{\partial \tilde{p}}{\partial r}\right) +\;+\; \frac{m^2 \bar{c}^2}{r^2}\,\tilde{p} +\;=\; (\gamma - 1)\,\lambda\,\hat{\dot{q}}[\tilde{p}]. \tag{9} +$$ + +Mode families: $m=0$ contains all longitudinal (1L, 2L, …) and radial (1R, …) modes; $m=1$ the first tangential (1T) family and its longitudinal mixes (1T1L, …); $m=2$ the 2T family; etc. The historically dangerous modes for impinging-doublet LREs — 1T, 1T1L — are obtained from $m=1$ alone, on a 2-D mesh that can be made brutally fine at negligible cost. Mode identification is by construction ($m$ is an input; radial/longitudinal order is read off the meridional shape), eliminating the eigenvector-forensics of general 3-D solves. Spinning versus standing character is degenerate at the linear axisymmetric level ($\pm m$ pairs coincide); the distinction becomes dynamical only with symmetry-breaking (baffles) or at finite amplitude, both flagged as 3-D/FDF-tier topics. + +### G. Weak form, discretization, and the discrete NLEVP + +Multiply Eq. (9) by a test function $\phi$, integrate over $\Omega$ with the axisymmetric measure $r\,dr\,dx$, integrate the divergence term by parts, and insert the Robin condition (8): + +$$ +\underbrace{\int_\Omega \bar{c}^2\,\nabla\tilde{p}\cdot\nabla\bar{\phi}\;r\,d\Omega}_{\text{stiffness } K} +\;+\; m^2 \underbrace{\int_\Omega \frac{\bar{c}^2}{r}\,\tilde{p}\,\bar{\phi}\;d\Omega}_{K_m} +\;+\; \lambda \underbrace{\int_{\Gamma_z} \frac{\bar{c}}{z(\lambda)}\,\tilde{p}\,\bar{\phi}\;r\,d\Gamma}_{C(\lambda)} +\;+\; \lambda^2 \underbrace{\int_\Omega \tilde{p}\,\bar{\phi}\;r\,d\Omega}_{M_2} +\;=\; (\gamma-1)\,\lambda \int_\Omega \hat{\dot{q}}[\tilde{p}]\,\bar{\phi}\;r\,d\Omega. \tag{10} +$$ + +Discretizing with $P^2$ Lagrange elements (FEniCSx [23]; gmsh meshes generated by revolving/meshing the chamber contour) gives sparse $N_h \times N_h$ matrices and the **discrete nonlinear eigenvalue problem** + +$$ +\boxed{\;N(\lambda)\,\mathbf{p} \;=\; \Big[\,K + m^2 K_m \;+\; \lambda\,C(\lambda) \;+\; \lambda^2 M_2 \;-\; \lambda\,F(\lambda)\,\Big]\,\mathbf{p} \;=\; 0,\;} \tag{11} +$$ + +where the flame matrix from Eq. (7a) is the rank-$k$ (one per reference ring) outer-product structure + +$$ +F(\lambda) \;=\; (\gamma-1) \sum_{k} e^{-\lambda \tau_k}\, \mathbf{g}_k\,\mathbf{b}_k^{\!\top}, +\qquad +\mathbf{g}_k = \Big[\textstyle\int_\Omega n_p \tfrac{\bar{\dot q}}{\bar p} N_i\, r\,d\Omega\Big]_i,\;\; +\mathbf{b}_k = \big[N_i(\mathbf{x}_{\mathrm{ref},k})\big]_i, +$$ + +(velocity coupling replaces $\mathbf{b}_k$ by the gradient-sampling functional and cancels one power of $\lambda$; the implementation treats both through a common `FlameOperator` abstraction). With passive flame ($F=0$) and constant $z$, Eq. (11) is a *quadratic* eigenproblem; delays and $z(\lambda)$ make it genuinely nonlinear but **holomorphic** in $\lambda$ — the property that licenses every solver in Section IV. All matrices except the scalar factors $e^{-\lambda\tau_k}$ and $1/z(\lambda)$ are assembled once per mean flow; a full $(n,\tau)$-envelope sweep therefore reuses the expensive objects wholesale. + +Function-of-interest evaluation, mirroring [3] Section III.D: the campaign extracts, per $(m, n, \tau)$ point, the set $\{\lambda_j\}$ in the analysis window, the mode shapes, the Rayleigh-integrand field $\mathrm{Re}[\tilde{p}^*\hat{\dot{q}}]\,$ (localizing driving), and the boundary-flux damping budget (attributing loss to nozzle/absorbers) — the last two being the diagnostic quantities a stability engineer acts on. + +--- + +## IV. Eigenvalue computation methodology + +### A. Structure of the problem and solution strategy + +We must find all eigenvalues of the holomorphic NLEVP (11) inside a target window $W = \{\lambda : |\mathrm{Im}\,\lambda|/2\pi \in [f_{\min}, f_{\max}],\ \mathrm{Re}\,\lambda \in [-\sigma_{\max}, +\sigma_{\max}]\}$ spanning the low-order mode families, *with certainty that none are missed* — a missed marginally-unstable mode is the worst failure of a stability tool. No single algorithm optimally provides speed, robustness, and completeness; we specify a hierarchy in which cheap iterations do the bulk of the work and a contour-integral method audits completeness. + +### B. Frozen-coefficient fixed point (workhorse) + +The AVSP strategy [6]: freeze the $\lambda$-dependence of the "slow" scalar factors at the current iterate and solve the resulting *quadratic* eigenproblem by linearization. + +**Algorithm 1 — Frozen-delay fixed point for mode $j$ at wavenumber $m$.** +``` +Input: matrices K, Km, M2; flame factors {g_k, b_k, τ_k}; z(λ); shift s0 + (from passive-flame mode or previous sweep point); tol η. +1: λ⁰ ← s0 +2: for it = 0, 1, 2, ... do +3: Freeze D_k ← exp(−λ^it τ_k), ζ ← z(λ^it) +4: Assemble quadratic pencil Q(λ) = K̃(D,ζ) + λ C̃(ζ) + λ² M2 +5: Companion-linearize to GEVP of size 2N_h (SLEPc PEP) +6: Solve by shift-invert Krylov–Schur, target s = λ^it ▷ Section IV.E +7: λ^{it+1} ← eigenvalue of Q nearest λ^it +8: if |λ^{it+1} − λ^it| < η |λ^{it+1}| : return (λ, p) +9: end for +``` +Convergence is linear with rate $\sim |n\,\tau\,\partial_\lambda(\cdot)|$; in practice 3–8 outer iterations for realistic LRE parameters [6]. Continuation through the $(n,\tau)$ sweep (warm-starting from the neighboring grid point) typically cuts this to 1–3. + +### C. Bordered Newton (polish and continuation) + +Given a good iterate, quadratic convergence is recovered by Newton on the extended system with normalization $\mathbf{c}^H\mathbf{p} = 1$: + +$$ +\begin{bmatrix} N(\lambda) & N'(\lambda)\,\mathbf{p} \\ \mathbf{c}^H & 0 \end{bmatrix} +\begin{bmatrix} \Delta\mathbf{p} \\ \Delta\lambda \end{bmatrix} += -\begin{bmatrix} N(\lambda)\,\mathbf{p} \\ \mathbf{c}^H\mathbf{p} - 1 \end{bmatrix}, +\qquad +N'(\lambda) = C + \lambda\,\partial_\lambda C + 2\lambda M_2 - \partial_\lambda(\lambda F), +$$ + +with the bordered solve performed by block elimination against the cached sparse factorization of $N(\lambda)$ (bordering algorithm; one back-solve pair per step). This is also the natural engine for **neutral-curve tracing**: appending the constraint $\mathrm{Re}\,\lambda = 0$ and freeing one flame parameter (say $n$) turns the same bordered system into a pseudo-arclength continuation for the stability boundary $n_{\mathrm{crit}}(\tau)$ directly — far cheaper than gridding the whole envelope when only the boundary is wanted. + +### D. Beyn contour integration (completeness audit) + +Missed-mode insurance is provided by the contour-integral method of Beyn [24]: for a contour $\Gamma \subset W$ enclosing the eigenvalues of interest and a random probe block $V \in \mathbb{C}^{N_h \times \ell}$, + +$$ +A_0 = \frac{1}{2\pi i}\oint_\Gamma N(z)^{-1} V \, dz, \qquad +A_1 = \frac{1}{2\pi i}\oint_\Gamma z\,N(z)^{-1} V \, dz, +$$ + +followed by an SVD-based rank reveal of $A_0$ and a small dense eigenproblem, returns **all** eigenpairs inside $\Gamma$ (holomorphy of $N$ guarantees it, up to quadrature error, which decays exponentially with node count for trapezoid rule on smooth contours). Cost: one sparse LU + $\ell$ back-solves per quadrature node ($\sim$16–32 nodes per window). Role: executed once per campaign per $m$-window at the envelope's worst-case corner, cross-checking the fixed-point/Newton mode census. SLEPc's NEP module with NLEIGS rational approximation [25] provides an alternative production path for the delay-type nonlinearity and will be benchmarked against the in-house Beyn implementation. + +### E. Shift-invert Krylov–Schur kernel + +All linearized solves reduce to: eigenvalues of a sparse pencil $(A, B)$ nearest a shift $s \in \mathbb{C}$. As in [3] (their Algorithm 3), we use shift-invert Krylov–Schur: + +$$ +(A - sB)^{-1} B\, \mathbf{q} = \theta\, \mathbf{q}, \qquad \lambda = s + 1/\theta, +$$ + +which maps the interior window around $s$ to the *exterior* (largest $|\theta|$) of the transformed spectrum, where Arnoldi converges in $O(10)$ iterations. The operator is applied via a **one-time complex sparse LU factorization** of $(A - sB)$ (MUMPS/SuperLU_DIST through PETSc); every Arnoldi step is then a mat-vec plus two triangular back-solves. At Helmholtz-tier sizes ($N_h \sim 10^5$, 2-D sparsity) factorization takes seconds and memory is trivial; the GMRES/BiCGSTAB inner-solver fallback becomes relevant only at the 3-D LNSE tier, where the factorization no longer fits — the interface (a `ShiftedSolve` protocol) is designed so direct and iterative applications are interchangeable. The time-stepper/Cayley alternative of [3], which avoids shift selection by spectral-transforming through a Crank–Nicolson propagator, is noted as the migration path for matrix-free LNSE tiers where $J$ is never assembled. + +Shift placement is not guesswork here — passive-flame acoustic frequencies (computable instantly, and validated against the existing module's duct formulas as a smoke test) seed one shift per expected mode family per $m$. + +### F. Campaign-level algorithm + +**Algorithm 2 — Stability campaign for one design.** +``` +Input: design config x_d (geometry, propellants, Pc, MR, injector pattern), + flame-parameter envelope Σ ⊂ (n, τ) space, wavenumbers m ∈ {0,1,2,3}, + frequency window [f_min, f_max]. +1: MeanFlowSpec ← parametric generator(x_d) ▷ Section V.B +2: MeanFlowSpec ← SU2 axisym RANS warm-started from (1) ▷ Section V.C [skippable in fast mode] +3: for m in {0,1,2,3}: +4: Assemble K, Km, M2, C-structure, flame vectors on meridional mesh +5: Solve passive problem (F=0) → mode census, frequencies, shapes ▷ seeds + sanity vs. duct formulas +6: for (n, τ) in Σ-grid (parallel): +7: for each tracked mode j: Algorithm 1 + Newton polish → λ_j(m; n, τ) +8: Trace neutral curves n_crit(τ) per mode by bordered continuation ▷ Section IV.C +9: Beyn audit on worst-case corner of Σ → assert census complete +10: Report: σ-maps over Σ per mode; margins (Sec. VI); mode shapes; + Rayleigh-integrand and damping-budget fields; JSON + plots. +``` + +--- + +## V. Mean flow generation + +### A. The `MeanFlowSpec` interface + +All eigensolver inputs pass through one container, decoupling mean-flow fidelity from stability machinery: + +```python +@dataclass +class MeanFlowSpec: + mesh: MeridionalMesh # (x, r) triangulation of revolved contour + rho: Field # mean density [kg/m^3] + c: Field # sound speed [m/s] + gamma: Field # specific heat ratio + qbar: Field # mean volumetric heat release [W/m^3] + ubar: Optional[VectorField] # mean velocity (None at Helmholtz tier; required for LEE) + refs: list[FlameReference] # per-ring reference points/normals for Eq. (7) + meta: ProvenanceRecord # generator, propellants, Pc, MR, residuals, hashes +``` + +Adapters populate it from (i) the parametric generator, (ii) an SU2 restart/solution file, or (iii, future) any external CFD/LES average. `ProvenanceRecord` makes every eigenvalue traceable to its mean flow — a professional-suite requirement. + +### B. Stage 1: parametric generator (all propellants, all injector types) + +Purpose: physically-consistent fields sufficient to (a) warm-start RANS reliably and (b) drive the eigensolver in fast/development mode. Construction: + +**Axial energy-release profile.** A burned-fraction profile $\psi(x)$ with vaporization/consumption length $L_v$: + +$$ +\psi(x) = 1 - \exp\!\big[-(x/L_v)^{k}\big], \qquad +\bar{\dot{q}}_{\mathrm{axial}}(x) \propto \frac{d\psi}{dx}, \qquad +\int_V \bar{\dot{q}}\, dV = \eta_{c^*}\,\dot{m}_p\,\Delta h_c, +$$ + +with $k \in [1,2]$ a spreading parameter and the total normalized to the delivered (efficiency-corrected) heat release. $L_v$ is propellant- and injector-specific: + +- *Liquid–liquid (LOX/RP-1, LOX/ethanol doublets):* vaporization-limited. Droplet $d_{32}$ from unlike-doublet impingement correlations (Dickerson-class [26]; inputs: orifice diameters, jet velocities, momentum ratio, impingement angle), droplet lifetime from the $d^2$-law with Spalding transfer number — directly reusing the suite's existing `spalding.py` — and $L_v \approx \bar{u}_d\, \tau_{vap}$ with axial drag-decelerated drop velocity. This is the Priem–Heidmann vaporization-limited chamber-length logic [27] recast as a profile generator. +- *Gas–gas / gas-centered (LOX/GCH₄):* mixing-limited; $L_v$ from turbulent jet-flame length scaling on element exit diameter and momentum-flux ratio. Methane's well-characterized kinetics (GRI-Mech 3.0 [28] via Cantera [29]) matter at Stage 2; at Stage 1 only $L_v$ and equilibrium properties enter. + +**Radial/pattern distribution.** Injector-type-specific weight $g(r)$ (azimuthally smeared per Section III.F): + +- *Unlike doublets in rings at radii $\{r_k\}$:* $g(r) = \sum_k w_k\, \mathcal{N}(r; r_k, s_k)$ — Gaussian annuli with widths set by element spacing and spray fan spreading; $w_k$ from per-ring mass flow. Ring-level mixture-ratio bias from Rupe mixing-uniformity $E_m$ [30] shifts local equilibrium temperature via CEA at the local MR (film-cooling/barrier rings thus appear naturally as cool outer strata — which measurably shift tangential-mode frequencies and damping). +- *Pintle:* single annular release zone at the impingement cone radius/angle, parameterized by the existing impingement-zone code. + +**Thermodynamic fields.** With local burned fraction $\Phi(x,r) \propto \psi(x)g(r)$ (normalized), blend injection-end and equilibrium states: + +$$ +\bar{T}(x,r) = T_{\mathrm{inj}} + \big[T_{ad}(\mathrm{MR}_{\mathrm{local}}, P_c) - T_{\mathrm{inj}}\big]\,\Phi(x,r),\quad +\bar{c} = \sqrt{\gamma R \bar{T}}, \quad \bar{\rho} = \frac{P_c}{R\,\bar{T}}, +$$ + +with $(T_{ad}, \gamma, R)(\mathrm{MR}, P_c)$ from the existing CEA cache. Every propellant/injector specialization above is a *submodel behind one interface*; adding a propellant is a chemistry-table entry plus (if liquid) property data for the $d^2$-law. + +### C. Stage 2: warm-started SU2 axisymmetric RANS + +Stage 1 fields, interpolated to the CFD mesh and written as an SU2 restart file, initialize a compressible axisymmetric RANS solve (SU2 [31]; SST closure) of chamber + nozzle on the revolved contour. Two combustion treatments, in order of implementation: + +1. **Prescribed heat source:** impose $\bar{\dot{q}}(x,r)$ from Stage 1 as a volumetric source; SU2 then returns *conservation-consistent* $\bar{\rho}, \bar{T}, \bar{u}$ fields (boundary layers, recirculation, nozzle acceleration) without any combustion-model risk. Fastest robust upgrade over Stage 1; flame *placement* remains modeled. +2. **FGM/flamelet species transport:** Cantera-built flamelet tables (GRI-3.0 for CH₄; ethanol mechanism; RP-1 surrogate) with SU2's species-transport/FGM machinery, letting the CFD position the flame. Higher fidelity, higher care: transcritical LOX injection at high $P_c$ is knowingly approximated (ideal-gas mixing with matched enthalpy flux) — acceptable for acoustic mean fields, flagged in provenance. + +Warm-starting is load-bearing, not cosmetic: steady reacting RANS initialized from uniform states routinely diverges; initialized from Stage 1 fields with CFL ramping it converges reliably and *faster*, and the Stage-1-vs-converged-RANS discrepancy is logged as calibration feedback to the parametric flame-shape submodels. Budget: 100–300k cells, deep convergence, ≲30 min on the target workstation (R1). + +--- + +## VI. Stability maps and margin definition + +Because $(n, \tau)$ carry the dominant uncertainty, the campaign sweeps an envelope $\Sigma$ (default: $n \in [0.3, 3]$, $\tau$ spanning $0.3\times$–$3\times$ the physics-based estimate of Section III.D, log-spaced grid, refined near neutral curves by the continuation of Section IV.C) and reports, per mode $j$: + +- growth-rate map $\sigma_j(n, \tau)$ and neutral curve $\mathcal{N}_j = \{(n,\tau): \sigma_j = 0\}$; +- **parametric margin** $\mathcal{M}_j = \min_{(n,\tau)\in\mathcal{N}_j} \big\| (n,\tau) - (\hat{n},\hat{\tau}) \big\|_{\Sigma}$ — the scaled distance from the nominal estimate to instability (signed: negative if the nominal point is already unstable); +- worst-case growth rate over $\Sigma$, $\sigma_j^{\max}$, and the fraction of $\Sigma$ that is unstable; +- classical interpretability check: the $\tau$-bands of instability for each mode should straddle $\tau \approx (2k{+}1)/(2f_j)$ (Rayleigh phase criterion) — a built-in physical sanity assertion on every map. + +A design verdict is then a table over mode families $\{$1L, 2L, 1T, 1T1L, 2T, 1R$\}$ × $\{\mathcal{M}_j, \sigma_j^{\max}, f_j\}$, plus mode-shape and Rayleigh-integrand plots — replacing the current module's scalar `stability_margin` with an artifact of the kind stability review boards actually consume [1]. The sweep is embarrassingly parallel and reuses all $\lambda$-independent matrices (Section III.G). + +--- + +## VII. Verification and validation plan + +Verification (math/code) is separated from validation (physics), each with acceptance criteria. All verification cases become permanent CI regression tests. + +### A. Verification ladder + +| # | Case | Reference | Checks | Acceptance | +|---|---|---|---|---| +| V1 | Uniform closed–closed cylinder, passive | Analytic: $f = \frac{c}{2\pi}\sqrt{(\alpha'_{mn}/R_c)^2 + (k\pi/L)^2}$, $J'_m(\alpha'_{mn})=0$ | FEM correctness, $m$-decomposition, axis conditions, convergence order | eigenvalue error $<0.1\%$ on production mesh; observed $O(h^{2p})$ convergence | +| V2 | 1-D duct with temperature jump, passive | Analytic dispersion relation (interface matching) | nonuniform-$\bar c$ handling | $<0.1\%$ | +| V3 | Duct with compact flame, $n$–$\tau$, closed/choked ends | Semi-analytic transcendental dispersion relation; Rijke-tube literature [17, 32] | active flame term, delay nonlinearity, complex $\lambda$, all three solvers agree | $|\Delta\lambda|/|\lambda| < 10^{-6}$ between solvers; $<1\%$ vs. dispersion relation | +| V4 | Published Helmholtz benchmark (AVSP-class annular/longitudinal config [6]; helmholtz-x examples [8]) | cross-code | end-to-end 2-D/3-D machinery, impedance BCs | frequencies $<1\%$, growth rates $<5\%$ | +| V5 | Time-domain cross-check: linearized Eq. (5) marched in time from impulse; $\sigma, \omega$ fitted from the linear-growth phase | self-consistency (mirrors Appendix G of [3]) | independent path to the same eigenvalue; catches sign/convention bugs | fitted vs. eigensolver $\lambda$: $<1\%$ | +| V6 | Nozzle admittance: compact limit vs. quasi-1-D admittance ODE as $\ell_{\mathrm{noz}}\to$ compact | [21, 22] | boundary-condition module | monotone convergence to Marble–Candel value | + +### B. Validation targets + +| Case | Facility/data | Why it fits | Success measure | +|---|---|---|---| +| CVRC | Purdue Continuously Variable Resonance Combustor: single-element CH₄/decomposed-H₂O₂, self-excited longitudinal instability, stability boundary vs. translating oxidizer-post length; extensively published [33] | public data; longitudinal ($m{=}0$) exercises flame + nozzle + impedance BCs; methane-relevant | predicted stable/unstable classification vs. post length reproduces the experimental boundary within the swept flame-parameter envelope; frequency within ~5% | +| BKD | DLR LOX/H₂ research thruster, injector-coupled 1T instability with published spectral/mode data [34]; LES-based analyses available for cross-reference [12] | transverse ($m{=}1$) validation on a real multi-element LRE | 1T frequency within ~5%; instability window qualitatively reproduced under documented flame-response assumptions | +| In-house | Future hot-fire campaigns (LOX/CH₄ doublet) | closes the loop on our own hardware | post-test: measured mode frequencies/growth vs. prediction; pre-test: margin table informs instrumentation | + +An explicit non-goal at this tier: quantitative growth-rate accuracy better than factor-~2 against experiment. The literature consensus [6, 9, 17] is that frequencies are predicted well, growth rates to leading order, and stable/unstable classification usefully — *provided* flame-parameter uncertainty is swept, which is exactly the campaign design. + +--- + +## VIII. Computational cost estimates + +Per design campaign on an Apple-Silicon workstation (estimates to be replaced by measurements; all stages checkpointed): + +| Stage | Size | Estimated cost | +|---|---|---| +| Stage-1 parametric fields | analytic + CEA cache | seconds | +| SU2 axisym RANS (warm-started) | 100–300k cells | ≲30 min (R1 budget) | +| FEM assembly per $m$ | $N_h \sim 5\times10^4$–$2\times10^5$ ($P^2$) | seconds | +| One sparse complex LU | 2-D sparsity | 1–10 s | +| One eigen-solve (Alg. 1 + Newton) | few LUs + Krylov | 5–60 s | +| Full campaign: 4 $m$'s × ~200 $\Sigma$-points × ~4 tracked modes, warm-started continuation, 8-way parallel | ~10³ eigen-solves, heavy reuse | 15–40 min | +| Beyn audits | 4 windows × ~24 nodes | minutes | + +Total: **~1 hour**, dominated by RANS — consistent with R1 and leaving headroom for mesh refinement or wider envelopes. Memory is trivial at 2-D sizes (<8 GB throughout). + +--- + +## IX. Software architecture and integration + +New package, existing code untouched (R6): + +``` +engine/stability_hifi/ + meanflow/ spec.py (MeanFlowSpec, adapters) + parametric.py (Sec. V.B; injector & propellant submodels) + su2_driver.py (restart writer, config gen, run, extract) + acoustics/ mesh.py (contour → meridional mesh via gmsh) + assembly.py (FEniCSx forms: K, Km, M2, C, flame ops) + bcs.py (impedance library: rigid, Marble–Candel, quasi-1D nozzle ODE, absorber patches) + eigen/ nlevp.py (N(λ) protocol), fixed_point.py, newton.py, + beyn.py, slepc_backend.py (PEP/NEP-NLEIGS), shifts.py + campaign/ sweep.py (Σ grids, continuation, parallel map) + margins.py, report.py (JSON + plots), provenance.py + validation/ cases/ (V1–V6 as CI tests), cvrc/, bkd/ +``` + +Dependency policy: `numpy/scipy` mandatory; `FEniCSx + SLEPc/PETSc` for production FEM/eigen (a `scipy.sparse + ARPACK` fallback backend keeps V1–V3 runnable in minimal environments); `gmsh`, `cantera` required; `SU2` optional (Stage 2). Entry point: `enginedesign stability-hifi run ` producing a versioned report directory. The existing lumped module keeps two permanent roles: `chug.py` remains the authoritative feed-coupled (low-frequency) analysis, and `acoustic.py`/`core.py` remain the in-loop screen, with their closed-form frequencies seeding Algorithm 2 step 5 and `core.lags_from_smd` seeding the $\Sigma$-envelope center. Cross-checks between the lumped overlap/damping assumptions and this framework's computed values are reported per campaign — each run of the rich tier calibrates the fast tier. + +Implementation phasing: + +- **P0 (eigensolver core):** V1–V3 on synthetic `MeanFlowSpec` fields; scipy backend; fixed-point + Newton. *Exit: V1–V3 green.* +- **P1 (real geometry + parametric mean flow):** contour meshing, Stage-1 generator (doublet + pintle, 3 propellants), Marble–Candel BC, campaign/report layer; V4–V6. *Exit: full campaign on a current in-house design, fast mode.* +- **P2 (CFD anchoring):** SU2 driver, heat-source then FGM; CVRC validation. *Exit: CVRC boundary reproduced.* +- **P3 (hardening):** Beyn audit productionized, SLEPc NEP benchmark, BKD case, documentation. + +--- + +## X. Roadmap beyond the Helmholtz tier + +| Tier | Physics added | Formulation change | Reuse | +|---|---|---|---| +| LEE | mean-flow convection, refraction, entropy/vorticity waves, intrinsic nozzle damping | 5-equation linearized Euler operator on $(\bar\rho,\bar{\mathbf u},\bar p)$; same $e^{im\theta}$ reduction; $\hat{q}$-vector grows to 4 fields/point (meridional) | mesh, `MeanFlowSpec` (now consuming $\bar{\mathbf u}$), all NLEVP solvers, campaign layer | +| FDF / limit cycle | amplitude-dependent flame response; limit-cycle amplitude & hysteresis prediction | harmonic-balance fixed point over amplitude wrapping the existing eigensolve; FDF tables from LES/experiment/correlations | everything; adds one outer loop | +| Non-normal / resolvent | transient growth, triggering susceptibility, forced response to injector noise | SVD of $(\lambda I - L)^{-1}$ via the same shift-invert kernel | operator assembly, Krylov kernel | +| 3-D | baffle sectors, discrete absorber arrays, azimuthally nonuniform patterns | full 3-D FEM; $m$ no longer separable (Bloch reduction where sector-periodic) | solvers, flame ops, campaign | +| LNSE | full linearized RANS global modes (the direct analogue of [3]) | matrix-free time-stepper/Cayley Arnoldi on the CFD Jacobian | eigensolver strategy, reporting | +| Adjoint sensitivities | $d\sigma/d\mathbf{x}_d$ for geometry/injector parameters — stability-constrained *design*, closing the loop back to [3]'s program | coupled adjoint of bottom+top levels; block back-substitution exactly as [3] Eq. (14)–(17) | entire two-level structure, by construction | + +The two-level architecture was chosen with the last row in mind: because the mean-flow residual does not depend on the eigenpair ($\partial\mathbf{r}/\partial\mathbf{v} = 0$), the coupled stability adjoint back-substitutes into two sequential adjoint solves [3] — meaning the eventual gradient capability requires no re-architecture, only differentiation of components that are, at the Helmholtz tier, small and mostly linear-algebraic. + +--- + +## XI. Conclusions + +We have specified, to implementation readiness, a first-principles combustion stability analysis capability for the EngineDesign suite: a thermoacoustic global-mode eigensolver on the true chamber geometry with distributed, time-delayed flame response and physical boundary damping; an azimuthal decomposition delivering the tangential modes that dominate LRE risk at 2-D cost; a nonlinear-eigenproblem solver hierarchy with a completeness audit; a propellant- and injector-agnostic two-stage mean-flow pipeline culminating in warm-started axisymmetric RANS; and an uncertainty-honest campaign product — per-mode stability maps and parametric margins — with a concrete verification ladder and public-data validation plan (CVRC, BKD). The formulation deliberately mirrors the two-level linear-stability architecture of Kanchi et al. [3], both because the mathematical structure (steady base state; Jacobian eigenvalue; shift-invert Krylov solution; eventual coupled adjoint) transfers intact from transonic buffet to thermoacoustics, and because that structure is what keeps every planned extension — LEE, describing functions, resolvent, adjoints — an upgrade rather than a rewrite. The result, when implemented, replaces heuristic scoring with the spectrum of the linearized dynamics: mode-by-mode growth rates, shapes, driving mechanisms, and margins, at a per-design cost of about an hour. + +--- + +## Appendix A. Real formulation of the complex eigenproblem + +Solvers operating in real arithmetic (and the future adjoint, following [3] Appendix A) use the split $\hat{\mathbf{q}} = \mathbf{q}_r + i\mathbf{q}_i$, $\lambda = \lambda_r + i\lambda_i$ applied to Eq. (3): + +$$ +\begin{aligned} +J\mathbf{q}_r + \lambda_r B\mathbf{q}_r - \lambda_i B\mathbf{q}_i &= 0,\\ +J\mathbf{q}_i + \lambda_r B\mathbf{q}_i + \lambda_i B\mathbf{q}_r &= 0, +\end{aligned} +$$ + +closed by two normalization conditions (e.g., $\mathbf{e}_k^\top\mathbf{q}_r = 1$, $\mathbf{e}_k^\top\mathbf{q}_i = 0$, fixing scale and phase), giving the top-level residual $\hat{\mathbf{r}}(\mathbf{v}) = 0$ with $\mathbf{v} = [\mathbf{q}_r^\top, \mathbf{q}_i^\top, \lambda_r, \lambda_i]^\top$ — the form required for bordered Newton and for eigenpair adjoints. Our production Helmholtz solvers work directly in complex arithmetic; this form is recorded for the adjoint tier. + +## Appendix B. Derivation assumptions for Eq. (6) + +From the reacting Euler equations, linearized: (i) $\bar{M}^2 \ll 1$ (dropped mean-convection terms are $O(\bar M)$ in the momentum/energy balances); (ii) $\gamma\bar p$ spatially uniform (chamber pressure drop $\ll P_c$); (iii) calorically-perfect perturbations about locally-varying mean properties ($\gamma(\mathbf{x})$ retained in coefficients, its perturbation neglected); (iv) species/entropy perturbations enter only through $\dot q'$ (no entropy-wave transport — restored at LEE tier); (v) frozen turbulent transport. Under (i)–(v), continuity+energy give $\partial_t p' + \gamma\bar p \nabla\!\cdot\!\mathbf{u}' = (\gamma-1)\dot q'$ and momentum gives $\bar\rho\,\partial_t\mathbf{u}' = -\nabla p'$; cross-differentiation yields Eq. (5) since $\gamma\bar p\,\nabla\!\cdot\!(\bar\rho^{-1}\nabla p') = \nabla\!\cdot\!(\bar c^2\nabla p')$. + +## Appendix C. Compact-nozzle admittance + +For a choked nozzle short relative to the acoustic wavelength, mass-flux conservation of the perturbed choking condition gives the chamber-side relation $\frac{\hat u}{\bar c} = \frac{\gamma-1}{2}\,\bar M_e\,\frac{\hat p}{\gamma \bar p}$ [21], i.e., specific admittance $y = (\gamma-1)\bar M_e/2$ (purely resistive: the compact choked nozzle always damps). Finite-length corrections make $y(\lambda)$ complex and are obtained by integrating the quasi-1-D admittance ODE through the convergent section [22]; the BC module exposes both behind one interface. + +## Appendix D. Units and nondimensionalization + +Assembled matrices use SI; reported eigenvalues are $f = \omega/2\pi$ [Hz] and $\sigma$ [1/s], plus nondimensional $\lambda^* = \lambda L_{\mathrm{ref}}/\bar c_{\mathrm{ref}}$ with $L_{\mathrm{ref}} = R_c$, $\bar c_{\mathrm{ref}} = \bar c(\text{nozzle entrance})$ for cross-design comparison. A useful engineering translation also reported per mode: the cycle increment $g_c = e^{2\pi\sigma/\omega} - 1$ (fractional amplitude growth per cycle), the quantity most directly comparable to bomb-test damp rates [1]. + +## Appendix E. Doublet flame-shape submodel data flow + +$(d_o, V_j, \theta_{\mathrm{imp}}, \mathrm{MR}_{\mathrm{ring}}, \dot m_{\mathrm{ring}}) \xrightarrow{\text{[26]}} d_{32} \xrightarrow{\ d^2\text{-law, } B_M\ (\texttt{spalding.py})} \tau_{vap} \xrightarrow{\ \bar u_d(x)\ } L_v \Rightarrow \psi(x)$; $(r_k, s_k, w_k)$ from face layout; $E_m$ [30] $\Rightarrow \mathrm{MR}_{\mathrm{local}} \Rightarrow T_{ad}$ stratification. Each arrow is a replaceable submodel; each output feeds both $\bar{\dot q}(x,r)$ (hence the flame matrix weights) and the $\tau$-envelope center. + +--- + +## References + +[1] Harrje, D. T., and Reardon, F. H. (eds.), *Liquid Propellant Rocket Combustion Instability*, NASA SP-194, 1972. + +[2] Oefelein, J. C., and Yang, V., "Comprehensive Review of Liquid-Propellant Combustion Instabilities in F-1 Engines," *Journal of Propulsion and Power*, Vol. 9, No. 5, 1993, pp. 657–677. + +[3] Kanchi, R. S., He, S., Jonsson, E., and Martins, J. R. R. A., "Buffet Alleviation via Linear Stability Adjoint," (reference paper for this document; formulation, shift-invert Krylov–Schur and Cayley time-stepper eigensolvers, coupled linear-stability adjoint). + +[4] Li, J., Yang, D., Luzzato, C., and Morgans, A. S., "OSCILOS: the open-source combustion instability low-order simulator," Imperial College London, technical report/software. + +[5] Emmert, T., Meindl, M., Jaensch, S., and Polifke, W., "Linear State Space Interconnect Modeling of Acoustic Systems (taX)," *Acta Acustica united with Acustica*, Vol. 102, 2016. + +[6] Nicoud, F., Benoit, L., Sensiau, C., and Poinsot, T., "Acoustic Modes in Combustors with Complex Impedances and Multidimensional Active Flames," *AIAA Journal*, Vol. 45, No. 2, 2007, pp. 426–441. + +[7] Camporeale, S. M., Fortunato, B., and Campa, G., "A Finite Element Method for Three-Dimensional Analysis of Thermo-acoustic Combustion Instability," *Journal of Engineering for Gas Turbines and Power*, Vol. 133, No. 1, 2011. + +[8] Ekici, E., et al., *helmholtz-x*: open-source FEniCSx-based thermoacoustic Helmholtz solver (software), University of Cambridge. + +[9] Schulze, M., and Sattelmayer, T., "Linear Stability Assessment of Cryogenic Rocket Engine Combustion via Linearized Euler Equations," papers in *Journal of Propulsion and Power* / CEAS Space Journal, 2015–2017. + +[10] Theofilis, V., "Global Linear Instability," *Annual Review of Fluid Mechanics*, Vol. 43, 2011, pp. 319–352. + +[11] Nichols, J. W., and Lele, S. K., "Global Modes and Transient Response of a Cold Supersonic Jet," *Journal of Fluid Mechanics*, Vol. 669, 2011, pp. 225–241. + +[12] Urbano, A., Selle, L., Staffelbach, G., Cuenot, B., Schmitt, T., Ducruix, S., and Candel, S., "Exploration of Combustion Instability in a Liquid Propellant Rocket Engine with Large Eddy Simulation," *Combustion and Flame*, Vol. 169, 2016, pp. 129–140. + +[13] Crouch, J. D., Garbaruk, A., and Magidov, D., "Predicting the Onset of Flow Unsteadiness Based on Global Instability," *Journal of Computational Physics*, Vol. 224, 2007, pp. 924–940. + +[14] Lehoucq, R. B., Sorensen, D. C., and Yang, C., *ARPACK Users' Guide*, SIAM, 1998. + +[15] Stewart, G. W., "A Krylov–Schur Algorithm for Large Eigenproblems," *SIAM Journal on Matrix Analysis and Applications*, Vol. 23, No. 3, 2002, pp. 601–614; Hernandez, V., Roman, J. E., and Vidal, V., "SLEPc: A Scalable and Flexible Toolkit for the Solution of Eigenvalue Problems," *ACM TOMS*, Vol. 31, No. 3, 2005. + +[16] Bagheri, S., Åkervik, E., Brandt, L., and Henningson, D. S., "Matrix-Free Methods for the Stability and Control of Boundary Layers," *AIAA Journal*, Vol. 47, No. 5, 2009. + +[17] Juniper, M. P., and Sujith, R. I., "Sensitivity and Nonlinearity of Thermoacoustic Oscillations," *Annual Review of Fluid Mechanics*, Vol. 50, 2018, pp. 661–689; Balasubramanian, K., and Sujith, R. I., "Thermoacoustic Instability in a Rijke Tube: Non-normality and Nonlinearity," *Physics of Fluids*, Vol. 20, 2008. + +[18] Culick, F. E. C., *Unsteady Motions in Combustion Chambers for Propulsion Systems*, RTO AGARDograph AG-AVT-039, 2006. + +[19] Crocco, L., and Cheng, S.-I., *Theory of Combustion Instability in Liquid Propellant Rocket Motors*, AGARDograph No. 8, Butterworths, 1956. + +[20] Noiray, N., Durox, D., Schuller, T., and Candel, S., "A Unified Framework for Nonlinear Combustion Instability Analysis Based on the Flame Describing Function," *Journal of Fluid Mechanics*, Vol. 615, 2008, pp. 139–167. + +[21] Marble, F. E., and Candel, S. M., "Acoustic Disturbance from Gas Non-uniformities Convected Through a Nozzle," *Journal of Sound and Vibration*, Vol. 55, No. 2, 1977, pp. 225–243. + +[22] Bell, W. A., and Zinn, B. T., "The Prediction of Three-Dimensional Liquid-Propellant Rocket Nozzle Admittances," NASA CR-121129, 1973 (and Crocco–Sirignano quasi-1-D admittance theory). + +[23] Alnaes, M. S., et al., "The FEniCS Project Version 1.5," *Archive of Numerical Software*, Vol. 3, 2015; and the DOLFINx successor (FEniCSx). + +[24] Beyn, W.-J., "An Integral Method for Solving Nonlinear Eigenvalue Problems," *Linear Algebra and its Applications*, Vol. 436, No. 10, 2012, pp. 3839–3863. + +[25] Güttel, S., Van Beeumen, R., Meerbergen, K., and Michiels, W., "NLEIGS: A Class of Fully Rational Krylov Methods for Nonlinear Eigenvalue Problems," *SIAM Journal on Scientific Computing*, Vol. 36, No. 6, 2014; Güttel, S., and Tisseur, F., "The Nonlinear Eigenvalue Problem," *Acta Numerica*, Vol. 26, 2017, pp. 1–94. + +[26] Dickerson, R. A., et al., "Correlation of Spray Injector Parameters with Rocket Engine Performance," AFRPL-TR-68-147, 1968; see also NASA SP-8089, *Liquid Rocket Engine Injectors*, 1976. + +[27] Priem, R. J., and Heidmann, M. F., "Propellant Vaporization as a Design Criterion for Rocket-Engine Combustion Chambers," NASA TR R-67, 1960. + +[28] Smith, G. P., et al., "GRI-Mech 3.0," http://combustion.berkeley.edu/gri-mech/. + +[29] Goodwin, D. G., Moffat, H. K., Schoegl, I., Speth, R. L., and Weber, B. W., *Cantera: An Object-Oriented Software Toolkit for Chemical Kinetics, Thermodynamics, and Transport Processes* (software). + +[30] Rupe, J. H., "The Liquid-Phase Mixing of a Pair of Impinging Streams," JPL Progress Report 20-195, 1953. + +[31] Economon, T. D., Palacios, F., Copeland, S. R., Lukaczyk, T. W., and Alonso, J. J., "SU2: An Open-Source Suite for Multiphysics Simulation and Design," *AIAA Journal*, Vol. 54, No. 3, 2016, pp. 828–846. + +[32] Dowling, A. P., "The Calculation of Thermoacoustic Oscillations," *Journal of Sound and Vibration*, Vol. 180, No. 4, 1995, pp. 557–581; Juniper, M. P., "Triggering in the Horizontal Rijke Tube: Non-normality, Transient Growth and Bypass Transition," *Journal of Fluid Mechanics*, Vol. 667, 2011, pp. 272–308. + +[33] Yu, Y. C., Sisco, J. C., Rosen, S., Madhav, A., and Anderson, W. E., "Spontaneous Longitudinal Combustion Instability in a Continuously-Variable Resonance Combustor," *Journal of Propulsion and Power*, Vol. 28, No. 5, 2012, pp. 876–887. + +[34] Gröning, S., Hardi, J. S., Suslov, D., and Oschwald, M., "Injector-Driven Combustion Instabilities in a Hydrogen/Oxygen Rocket Combustor," *Journal of Propulsion and Power*, Vol. 32, No. 3, 2016, pp. 560–573. + +[35] EngineDesign project, "Combustion and Feed-System Stability for a Pressure-Fed LOX/CH₄ Unlike-Doublet Engine," `docs/stability/combustion_stability_physics.md`, v0.2 (companion physics reference for the lumped model in `engine/pipeline/stability/`). diff --git a/EngineDesign/engine/accel/kernels.py b/EngineDesign/engine/accel/kernels.py index 90ec1ce63..e484fe8eb 100644 --- a/EngineDesign/engine/accel/kernels.py +++ b/EngineDesign/engine/accel/kernels.py @@ -966,8 +966,11 @@ def _solve_exit_mach(eps, g): @njit(cache=True) def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, Pa): """Returns (ok, Pc, F, Isp, MR, cstar_actual, gamma, Tc, mdot_total, v_exit, Cf_actual).""" - Pc_min = 100000.0 - Pc_max = min(P_O, P_F)*(1.0 - 0.15) + # Search window and bracket scan: VERBATIM mirror of engine/core/chamber_solver.py + # (PC_CHOKE_FLOOR_PA, PC_MIN_TOTAL_DROP_FRAC, ChamberSolver._highest_sign_change). The parity + # gate requires the two root-finds to agree, so change both or neither. + Pc_min = 2.0*101325.0 + Pc_max = min(P_O, P_F)*(1.0 - 0.02) Pc_min = max(Pc_min, P[SV_PCMIN]); Pc_max = min(Pc_max, P[SV_PCMAX]) if Pc_max <= Pc_min: return (0.0,)*22 @@ -976,41 +979,28 @@ def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F rmax = _residual(Pc_max, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) if not np.isfinite(rmin) or not np.isfinite(rmax): return (0.0,)*22 - if _sign(rmin) == _sign(rmax): - # The residual is not always monotonic in Pc: on the canonical impinging design at - # asymmetric tank pressures it is NEGATIVE at Pc_min, turns POSITIVE mid-range, and goes - # negative again by Pc_max, so an endpoint-only sign test sees "no root" and bails where - # a root plainly exists (measured: -0.214 at 1.0 bar, +0.76 at 20 bar, -0.96 at 27.4 bar, - # root at 23.75 bar). Scan for a sign-changing sub-interval before giving up -- the pure - # Python solver finds these, and the parity gate requires the accelerator to agree. - # Scan from the HIGH end down: a non-monotonic residual can also cross near Pc_min - # (a spurious low-pressure crossing where the chamber is barely flowing). The physical - # operating point is the HIGHEST-Pc root, which is the one the pure Python solver - # converges to; taking the first crossing from the bottom picked the spurious one and - # diverged from Python by 3.3x. - _n = 32 - _lo = 0.0; _hi = 0.0; _found = False - _pb = Pc_max; _rb = rmax - for _i in range(_n - 1, -1, -1): - _pa = Pc_min + (Pc_max - Pc_min)*(_i/_n) - _ra = _residual(_pa, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) - if np.isfinite(_ra) and np.isfinite(_rb) and _sign(_ra) != _sign(_rb): - _lo = _pa; _hi = _pb; _found = True - break - _pb = _pa; _rb = _ra - if _found: - Pc = _brentq(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, - _lo, _hi, xtol, rtol, maxit) - if not np.isfinite(Pc): - return (0.0,)*22 - elif rmin > 0 and rmax > 0 and rmax < 0.1: - Pc = Pc_max - else: - return (0.0,)*22 - else: - Pc = _brentq(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, Pc_min, Pc_max, xtol, rtol, maxit) - if not np.isfinite(Pc): - return (0.0,)*22 + # The residual is not monotonic in Pc: it is negative near the choking floor (barely flowing, + # the spurious crossing), positive through the operating range, and negative again once the + # injector drop gets small. The physical operating point is the HIGHEST-Pc root, so scan from + # Pc_max down for the first sign change and bracket Brent inside it. Endpoint-only tests + # missed interior roots (measured on canonical/impinging: -0.214 at 1 bar, +0.76 at 20 bar, + # -0.96 at 27.4 bar, root at 23.75 bar) and a whole-window Brent picked the spurious low one. + _n = 32 + _lo = 0.0; _hi = 0.0; _found = False + _pb = Pc_max; _rb = rmax + for _i in range(_n - 1, -1, -1): + _pa = Pc_min + (Pc_max - Pc_min)*(_i/_n) + _ra = rmin if _i == 0 else _residual(_pa, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) + if np.isfinite(_ra) and np.isfinite(_rb) and _sign(_ra) != _sign(_rb): + _lo = _pa; _hi = _pb; _found = True + break + _pb = _pa; _rb = _ra + if not _found: + return (0.0,)*22 + Pc = _brentq(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, + _lo, _hi, xtol, rtol, maxit) + if not np.isfinite(Pc): + return (0.0,)*22 # recompute converged state (ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF, dpfO, dpfF, WeO, WeF, urel, xstar, constr, nit, tiO, tiF) = _solve_injector(P, P_O, P_F, Pc) diff --git a/EngineDesign/engine/control/robust_ddp/data_models.py b/EngineDesign/engine/control/robust_ddp/data_models.py index b10df9b36..54eb122f7 100644 --- a/EngineDesign/engine/control/robust_ddp/data_models.py +++ b/EngineDesign/engine/control/robust_ddp/data_models.py @@ -267,6 +267,15 @@ class ControllerConfig: # Regulator model reg_setpoint: Optional[float] = None # Regulator setpoint [Pa] (None = derived from COPV) reg_ratio: float = 0.8 # P_reg / P_copv ratio if setpoint not specified + # Pressurisation-path hardware. Previously literals inside dynamics.step(); surfaced here so + # a vehicle with a different regulator or solenoid can be modelled without editing code. + # See docs/adr/0001 -- this chain is one of the feed models lib/feedtwin absorbs. + gamma_gas: float = 1.4 # Pressurant specific heat ratio (N2) + Cd_regulator: float = 0.7 # Discharge coefficient, regulator orifice + Cd_valve: float = 0.65 # Discharge coefficient, solenoid valve orifice + A_regulator: float = 2e-5 # Regulator orifice area [m^2] + A_valve_F: float = 5e-5 # Fuel solenoid valve flow area [m^2] + A_valve_O: float = 5e-5 # Oxidiser solenoid valve flow area [m^2] # Ullage pressurization flow coefficients [1/s] alpha_F: float = 10.0 # Fuel pressurization flow coefficient diff --git a/EngineDesign/engine/control/robust_ddp/dynamics.py b/EngineDesign/engine/control/robust_ddp/dynamics.py index 2021ea67f..644766c23 100644 --- a/EngineDesign/engine/control/robust_ddp/dynamics.py +++ b/EngineDesign/engine/control/robust_ddp/dynamics.py @@ -63,6 +63,19 @@ class DynamicsParams: T_gas_copv_initial: float = 293.0 # Initial COPV gas temperature [K] T_gas_F_initial: float = 293.0 # Initial fuel tank gas temperature [K] T_gas_O_initial: float = 250.0 # Initial oxidizer tank gas temperature [K] (LOX tank is colder) + + # --- Pressurisation-path hardware ------------------------------------------------------ + # These were literals inside step(): a COPV -> regulator -> tank chain with the orifice + # geometry baked into the function body, which is one of the four fragmented feed models + # docs/adr/0001 calls out. They are real hardware numbers a user must be able to set once + # this path is calibrated or replaced by lib/feedtwin. Defaults are exactly the values that + # were hardcoded, so behaviour is unchanged unless someone overrides them. + gamma_gas: float = 1.4 # Specific heat ratio of the pressurant (N2) + Cd_regulator: float = 0.7 # Discharge coefficient, regulator orifice + Cd_valve: float = 0.65 # Discharge coefficient, solenoid valve orifice + A_regulator: float = 2e-5 # Regulator orifice area [m^2] (~20 mm^2) + A_valve_F: float = 5e-5 # Fuel solenoid valve flow area [m^2] (~50 mm^2) + A_valve_O: float = 5e-5 # Oxidiser solenoid valve flow area [m^2] (~50 mm^2) @classmethod def from_config(cls, config: ControllerConfig) -> DynamicsParams: @@ -90,6 +103,13 @@ def from_config(cls, config: ControllerConfig) -> DynamicsParams: T_gas_copv_initial=getattr(config, 'T_gas_copv_initial', 293.0), # COPV initial temp T_gas_F_initial=getattr(config, 'T_gas_F_initial', 293.0), # Fuel tank initial temp T_gas_O_initial=getattr(config, 'T_gas_O_initial', 250.0), # LOX tank initial temp (colder) + # Pressurisation-path hardware; getattr so an older ControllerConfig still loads. + gamma_gas=getattr(config, 'gamma_gas', 1.4), + Cd_regulator=getattr(config, 'Cd_regulator', 0.7), + Cd_valve=getattr(config, 'Cd_valve', 0.65), + A_regulator=getattr(config, 'A_regulator', 2e-5), + A_valve_F=getattr(config, 'A_valve_F', 5e-5), + A_valve_O=getattr(config, 'A_valve_O', 5e-5), ) @@ -160,17 +180,22 @@ def step( # n = 1.4: adiabatic (no heat transfer, γ for diatomic gas) # n = 1.2: typical for blowdown (some heat transfer with tank walls) - # Initialize temperatures on first call (store in function attributes) - if not hasattr(step, '_temp_initialized'): - step._T_copv_0 = getattr(params, 'T_gas_copv_initial', params.T_gas) - step._T_F_0 = getattr(params, 'T_gas_F_initial', params.T_gas) - step._T_O_0 = getattr(params, 'T_gas_O_initial', 250.0) # LOX tank colder - step._m_copv_0 = m_gas_copv - step._m_F_0 = m_gas_F - step._m_O_0 = m_gas_O - step._V_F_0 = V_u_F - step._V_O_0 = V_u_O - step._temp_initialized = True + # Reference state for the polytropic relation, captured on this params object's first + # step. It used to live on the FUNCTION object as private attributes, which made it + # process-global: the first trajectory ever stepped set the reference for every later + # one, across configs. An optimizer evaluating thousands of candidates -- exactly the + # Layer X access pattern docs/adr/0001 describes -- would hand candidates 2..N the + # reference state of candidate 1. Scoping it to `params` makes it per-config. + if not getattr(params, '_temp_initialized', False): + params._T_copv_0 = getattr(params, 'T_gas_copv_initial', params.T_gas) + params._T_F_0 = getattr(params, 'T_gas_F_initial', params.T_gas) + params._T_O_0 = getattr(params, 'T_gas_O_initial', 250.0) # LOX tank colder + params._m_copv_0 = m_gas_copv + params._m_F_0 = m_gas_F + params._m_O_0 = m_gas_O + params._V_F_0 = V_u_F + params._V_O_0 = V_u_O + params._temp_initialized = True # Compute current gas temperatures using polytropic relation # T = T0 * (rho/rho0)^(n-1) = T0 * (m/V) / (m0/V0))^(n-1) @@ -180,45 +205,45 @@ def step( if use_polytropic: # COPV temperature: polytropic expansion/compression - if step._m_copv_0 > 1e-10 and params.V_copv > 1e-10: - rho_copv_0 = step._m_copv_0 / params.V_copv + if params._m_copv_0 > 1e-10 and params.V_copv > 1e-10: + rho_copv_0 = params._m_copv_0 / params.V_copv rho_copv = m_gas_copv / params.V_copv if params.V_copv > 1e-10 else rho_copv_0 if rho_copv_0 > 1e-10: - T_copv = step._T_copv_0 * (rho_copv / rho_copv_0) ** (n_poly - 1.0) + T_copv = params._T_copv_0 * (rho_copv / rho_copv_0) ** (n_poly - 1.0) T_copv = max(200.0, min(400.0, T_copv)) # Clamp to reasonable range [200-400 K] else: - T_copv = step._T_copv_0 + T_copv = params._T_copv_0 else: - T_copv = step._T_copv_0 + T_copv = params._T_copv_0 # Fuel tank temperature: polytropic expansion/compression - if step._m_F_0 > 1e-10 and step._V_F_0 > 1e-10: - rho_F_0 = step._m_F_0 / step._V_F_0 + if params._m_F_0 > 1e-10 and params._V_F_0 > 1e-10: + rho_F_0 = params._m_F_0 / params._V_F_0 rho_F = m_gas_F / V_u_F if V_u_F > 1e-10 else rho_F_0 if rho_F_0 > 1e-10: - T_gas_F = step._T_F_0 * (rho_F / rho_F_0) ** (n_poly - 1.0) + T_gas_F = params._T_F_0 * (rho_F / rho_F_0) ** (n_poly - 1.0) T_gas_F = max(200.0, min(400.0, T_gas_F)) # Clamp to reasonable range else: - T_gas_F = step._T_F_0 + T_gas_F = params._T_F_0 else: - T_gas_F = step._T_F_0 + T_gas_F = params._T_F_0 # Oxidizer tank temperature: polytropic expansion/compression - if step._m_O_0 > 1e-10 and step._V_O_0 > 1e-10: - rho_O_0 = step._m_O_0 / step._V_O_0 + if params._m_O_0 > 1e-10 and params._V_O_0 > 1e-10: + rho_O_0 = params._m_O_0 / params._V_O_0 rho_O = m_gas_O / V_u_O if V_u_O > 1e-10 else rho_O_0 if rho_O_0 > 1e-10: - T_gas_O = step._T_O_0 * (rho_O / rho_O_0) ** (n_poly - 1.0) + T_gas_O = params._T_O_0 * (rho_O / rho_O_0) ** (n_poly - 1.0) T_gas_O = max(200.0, min(400.0, T_gas_O)) # Clamp to reasonable range else: - T_gas_O = step._T_O_0 + T_gas_O = params._T_O_0 else: - T_gas_O = step._T_O_0 + T_gas_O = params._T_O_0 else: # Isothermal process: temperature constant - T_copv = step._T_copv_0 - T_gas_F = step._T_F_0 - T_gas_O = step._T_O_0 + T_copv = params._T_copv_0 + T_gas_F = params._T_F_0 + T_gas_O = params._T_O_0 # Extract control u_F = np.clip(u[IDX_U_F], 0.0, 1.0) @@ -228,15 +253,16 @@ def step( # Flow path: COPV -> Regulator -> Tanks (when valves open) # Model: Compressible gas flow through orifices with proper choked/subsonic flow - # Physical constants for N2 gas - gamma_gas = 1.4 # Specific heat ratio for N2 - Cd_regulator = 0.7 # Discharge coefficient for regulator orifice - Cd_valve = 0.65 # Discharge coefficient for solenoid valve orifice - - # Effective flow areas [m²] - typical solenoid valve characteristics - A_regulator = 2e-5 # Regulator orifice area (~20 mm²) - A_valve_F = 5e-5 # Fuel solenoid valve flow area (~50 mm²) - A_valve_O = 5e-5 # Oxidizer solenoid valve flow area (~50 mm²) + # Pressurant properties and orifice geometry now come from DynamicsParams rather than + # being literals here -- same defaults, but settable per vehicle. See docs/adr/0001: this + # COPV -> regulator -> tank chain is one of the feed models lib/feedtwin absorbs. + gamma_gas = params.gamma_gas + Cd_regulator = params.Cd_regulator + Cd_valve = params.Cd_valve + + A_regulator = params.A_regulator + A_valve_F = params.A_valve_F + A_valve_O = params.A_valve_O # Gas flow from COPV to regulator [kg/s] # CRITICAL: Flow ONLY happens when at least one valve is open (u > 0) @@ -445,11 +471,11 @@ def step( # This is the key: limited gas supply + temperature drop means pressure drops faster if params.V_copv > 1e-10: # Update COPV temperature for next step (polytropic expansion) - if use_polytropic and step._m_copv_0 > 1e-10: + if use_polytropic and params._m_copv_0 > 1e-10: rho_copv_next = m_gas_copv_next / params.V_copv - rho_copv_0 = step._m_copv_0 / params.V_copv + rho_copv_0 = params._m_copv_0 / params.V_copv if rho_copv_0 > 1e-10: - T_copv_next = step._T_copv_0 * (rho_copv_next / rho_copv_0) ** (n_poly - 1.0) + T_copv_next = params._T_copv_0 * (rho_copv_next / rho_copv_0) ** (n_poly - 1.0) T_copv_next = max(200.0, min(400.0, T_copv_next)) else: T_copv_next = T_copv @@ -649,11 +675,11 @@ def step( # - When m increases faster than V, pressure increases (pressurization) if V_u_F_next > 1e-10: # Update fuel tank temperature for next step (polytropic expansion) - if use_polytropic and step._m_F_0 > 1e-10 and step._V_F_0 > 1e-10: + if use_polytropic and params._m_F_0 > 1e-10 and params._V_F_0 > 1e-10: rho_F_next = m_gas_F_next / V_u_F_next - rho_F_0 = step._m_F_0 / step._V_F_0 + rho_F_0 = params._m_F_0 / params._V_F_0 if rho_F_0 > 1e-10: - T_gas_F_next = step._T_F_0 * (rho_F_next / rho_F_0) ** (n_poly - 1.0) + T_gas_F_next = params._T_F_0 * (rho_F_next / rho_F_0) ** (n_poly - 1.0) T_gas_F_next = max(200.0, min(400.0, T_gas_F_next)) else: T_gas_F_next = T_gas_F @@ -700,11 +726,11 @@ def step( # This is the realistic behavior: flow begins -> ullage grows -> T drops -> pressure drops instantly if V_u_O_next > 1e-10: # Update oxidizer tank temperature for next step (polytropic expansion) - if use_polytropic and step._m_O_0 > 1e-10 and step._V_O_0 > 1e-10: + if use_polytropic and params._m_O_0 > 1e-10 and params._V_O_0 > 1e-10: rho_O_next = m_gas_O_next / V_u_O_next - rho_O_0 = step._m_O_0 / step._V_O_0 + rho_O_0 = params._m_O_0 / params._V_O_0 if rho_O_0 > 1e-10: - T_gas_O_next = step._T_O_0 * (rho_O_next / rho_O_0) ** (n_poly - 1.0) + T_gas_O_next = params._T_O_0 * (rho_O_next / rho_O_0) ** (n_poly - 1.0) T_gas_O_next = max(200.0, min(400.0, T_gas_O_next)) else: T_gas_O_next = T_gas_O diff --git a/EngineDesign/engine/core/chamber_solver.py b/EngineDesign/engine/core/chamber_solver.py index a83e11188..8a25a61b3 100644 --- a/EngineDesign/engine/core/chamber_solver.py +++ b/EngineDesign/engine/core/chamber_solver.py @@ -42,6 +42,23 @@ from engine.core.closure import flows +# Chamber-pressure search window. Mirrored VERBATIM in engine/accel/kernels.evaluate_core -- the +# parity gate requires the two root-finds to agree, so change both or neither. +# +# Floor: the throat must be sonic for the demand model mdot = Pc*At/c* to hold, which needs +# Pc > P_amb / (2/(gamma+1))^(gamma/(gamma-1)), i.e. ~1.8 atm across gamma 1.1-1.3. Below that the +# efficiency model collapses, demand blows up, and the residual has a spurious zero crossing near +# 20 psi: with the old 1 bar floor Brent converged to it and reported NEGATIVE thrust for the +# shipped default config at its own configured tank pressures. +PC_CHOKE_FLOOR_PA = 2.0 * 101325.0 +# Ceiling: Pc sits below the lower tank pressure by at least this fraction of it -- a floor on the +# total feed + injector drop. It is a search bound, not a design rule; the previous 15% "feed loss +# margin" was a guess that excluded the real operating point of any soft-injector case. +PC_MIN_TOTAL_DROP_FRAC = 0.02 +# Equal-step scan resolution used to locate the highest-Pc sign change before Brent. +PC_BRACKET_SCAN_POINTS = 32 + + class ChamberSolver: """Solves for chamber pressure by balancing supply and demand""" @@ -258,6 +275,88 @@ def _accel_chamber_pc(self, P_tank_O: float, P_tank_F: float): return None return Pc_native + @staticmethod + def _highest_sign_change(f, Pc_min: float, Pc_max: float, r_min: float, r_max: float, + n: int = PC_BRACKET_SCAN_POINTS): + """[lo, hi] holding the HIGHEST-Pc sign change of the residual on [Pc_min, Pc_max], or + (None, None). Equal steps scanned from the top; ``r_min``/``r_max`` are reused at the ends. + Verbatim mirror of the scan in accel.kernels.evaluate_core (parity).""" + pb, rb = Pc_max, r_max + for i in range(n - 1, -1, -1): + pa = Pc_min + (Pc_max - Pc_min) * (i / n) + ra = r_min if i == 0 else f(pa) + if np.isfinite(ra) and np.isfinite(rb) and np.sign(ra) != np.sign(rb): + return pa, pb + pb, rb = pa, ra + return None, None + + def _raise_supply_exceeds_demand(self, P_tank_O, P_tank_F, Pc_max, residual_min, residual_max, debug): + """Supply > demand even at the ceiling: injector oversized / throat undersized for these + pressures. Raise with the supply/demand numbers at Pc_max so the message is actionable.""" + try: + mdot_O_test, mdot_F_test, diag_test = flows(P_tank_O, P_tank_F, Pc_max, self.config) + mdot_supply_test = mdot_O_test + mdot_F_test + MR_test = mdot_O_test / mdot_F_test if mdot_F_test > 0 else np.inf + cg = ensure_chamber_geometry(self.config) + cea_props_test = self.cea_cache.eval(MR_test, Pc_max, 101325.0, cg.expansion_ratio) + cstar_ideal_test = cea_props_test.get("cstar_ideal", 0.0) + geometry_test = self._get_chamber_geometry() + advanced_params_test = { + "Pc": Pc_max, + "Tc": cea_props_test.get("Tc", DEFAULT_CHAMBER_TEMP_K), + "cstar_ideal": cstar_ideal_test, + "gamma": cea_props_test.get("gamma", DEFAULT_GAMMA_ND), + "R": cea_props_test.get("R", DEFAULT_GAS_CONST_J_KG_K), + "MR": MR_test, + "Ac": geometry_test["area_cross"], + "At": cg.A_throat, + "chamber_length": geometry_test["length"], + "Dinj": self._infer_injector_diameter(), + "m_dot_total": mdot_supply_test, + "spray_diagnostics": diag_test, + "turbulence_intensity": diag_test.get("turbulence_intensity_mix", DEFAULT_TURBULENCE_INTENSITY_ND), + "fuel_props": self._get_fuel_props(), + } + eta_test = eta_cstar( + calculate_Lstar(cg.volume, cg.A_throat, Lstar_override=cg.Lstar), + self.config.combustion.efficiency, + diag_test.get("cooling_efficiency", 1.0), + advanced_params_test, + debug=debug, + ) + cstar_actual_test = eta_test * cstar_ideal_test + mdot_demand_test = (Pc_max * cg.A_throat) / cstar_actual_test if cstar_actual_test > 0 else np.inf + if mdot_demand_test > 0 and mdot_supply_test > mdot_demand_test: + Pc_estimate = mdot_supply_test * cstar_actual_test / cg.A_throat + raise ValueError( + f"No solution: Supply > Demand at all Pc. " + f"Residual at Pc_min: {residual_min:.4f} kg/s, at Pc_max: {residual_max:.4f} kg/s. " + f"\nDiagnostics at Pc_max ({Pc_max/1e6:.2f} MPa):" + f"\n - Supply: {mdot_supply_test:.4f} kg/s (mdot_O={mdot_O_test:.4f}, mdot_F={mdot_F_test:.4f})" + f"\n - Demand: {mdot_demand_test:.4f} kg/s (c*_actual={cstar_actual_test:.1f} m/s, At={cg.A_throat*1e6:.2f} mm²)" + f"\n - Estimated Pc needed: {Pc_estimate/1e6:.2f} MPa (vs Pc_max={Pc_max/1e6:.2f} MPa)" + f"\nPossible fixes:" + f"\n 1. Reduce injector orifice areas (currently oversized)" + f"\n 2. Increase throat area (currently undersized)" + f"\n 3. Increase tank pressures to allow higher Pc_max" + f"\n 4. Check combustion efficiency (low efficiency reduces demand)" + ) + raise ValueError( + f"No solution: Supply > Demand at all Pc. " + f"Residual: [{residual_min:.4f}, {residual_max:.4f}] kg/s. " + f"Could not compute detailed diagnostics." + ) + except ValueError: + raise + except Exception as diag_e: + raise ValueError( + f"No solution: Supply > Demand at all Pc. " + f"Residual at bounds: [{residual_min:.4f}, {residual_max:.4f}] kg/s. " + f"Pc_max ({Pc_max/1e6:.2f} MPa) limited by tank pressure. " + f"Possible causes: Injector oversized, throat undersized, or combustion efficiency too low. " + f"Diagnostic error: {diag_e}" + ) + def solve( self, P_tank_O: float, @@ -285,24 +384,10 @@ def solve( diagnostics : dict Solution diagnostics """ - # Determine bounds - # Realistic bounds: Pc must be less than both tank pressures (accounting for feed losses) - Pc_min = 100000.0 # 100 kPa minimum - - # Estimate maximum feed losses for bounds calculation - # Use rough estimates: assume maximum flow gives ~10-20% pressure drop - # This is conservative but better than fixed 5% margin - # Actual feed losses will be calculated during solve - feed_loss_margin = 0.15 # 15% margin for feed losses (conservative estimate) - Pc_max = min(P_tank_O, P_tank_F) * (1.0 - feed_loss_margin) - - # If fuel pressure is much higher than oxidizer, we might need to allow - # Pc up to oxidizer pressure (since oxidizer flow limits the system) - # But this is already handled by min(P_tank_O, P_tank_F) - - # Clamp to config bounds - Pc_min = max(Pc_min, self.config.solver.Pc_bounds[0]) - Pc_max = min(Pc_max, self.config.solver.Pc_bounds[1]) + # Search window: choked-flow floor, tank-pressure ceiling (see the constants above), then + # the user's solver.Pc_bounds narrow it further. + Pc_min = max(PC_CHOKE_FLOOR_PA, self.config.solver.Pc_bounds[0]) + Pc_max = min(min(P_tank_O, P_tank_F) * (1.0 - PC_MIN_TOTAL_DROP_FRAC), self.config.solver.Pc_bounds[1]) if Pc_max <= Pc_min: raise ValueError(f"Invalid pressure bounds: Pc_max ({Pc_max}) <= Pc_min ({Pc_min})") @@ -325,259 +410,94 @@ def solve( def residual_func(Pc): return self.residual(Pc, P_tank_O, P_tank_F) - # Native fast path: run the whole residual loop + Brent in C when native can - # handle this config. On success skip the Python root-find. Importantly, this - # path is hit by the ~30% of Layer-1 CMA candidates that don't converge in the - # single-call ed_evaluate seam and fall back to runner.evaluate -> here, so - # keeping it native keeps that fallback fast (a pure-Python Brent solve here is - # ~100x slower). Any failure -> Python Brent below. + # Accelerated path: the whole residual loop + Brent in the numba kernel when it can + # handle this config. Importantly, this path is hit by the ~30% of Layer-1 CMA candidates + # that don't converge in the single-call seam and fall back to runner.evaluate -> here, so + # keeping it accelerated keeps that fallback fast (a pure-Python Brent solve here is ~100x + # slower). Any failure -> Python Brent below. + convergence_history: list = [] _accel_pc = self._accel_chamber_pc(P_tank_O, P_tank_F) if _accel_pc is not None: Pc = _accel_pc success = True - skip_solve = True - residual_min, residual_max = -1.0, 1.0 else: - # Check residual signs at bounds before solving residual_min = residual_func(Pc_min) residual_max = residual_func(Pc_max) - - # Check for NaN values and provide better error messages - if not np.isfinite(residual_min): - # Try to diagnose the issue - try: - # Test a few points to see where it fails - test_Pc = (Pc_min + Pc_max) / 2 - test_res = residual_func(test_Pc) - if not np.isfinite(test_res): + + if not np.isfinite(residual_min): + # Try to diagnose the issue + try: + test_res = residual_func((Pc_min + Pc_max) / 2) + if not np.isfinite(test_res): + raise ValueError( + f"Residual function returns non-finite values. " + f"Pc_min={Pc_min/1e6:.2f} MPa, Pc_max={Pc_max/1e6:.2f} MPa. " + f"Check injector geometry, feed system, or CEA cache." + ) + except Exception as e: raise ValueError( - f"Residual function returns non-finite values. " + f"Residual function evaluation failed at bounds. " f"Pc_min={Pc_min/1e6:.2f} MPa, Pc_max={Pc_max/1e6:.2f} MPa. " - f"Check injector geometry, feed system, or CEA cache." + f"Error: {e}" ) - except Exception as e: + if not np.isfinite(residual_max): raise ValueError( - f"Residual function evaluation failed at bounds. " - f"Pc_min={Pc_min/1e6:.2f} MPa, Pc_max={Pc_max/1e6:.2f} MPa. " - f"Error: {e}" + f"Residual function returns non-finite at Pc_max={Pc_max/1e6:.2f} MPa. " + f"Check that tank pressures are sufficient and injector geometry is valid." ) - - if not np.isfinite(residual_max): - raise ValueError( - f"Residual function returns non-finite at Pc_max={Pc_max/1e6:.2f} MPa. " - f"Check that tank pressures are sufficient and injector geometry is valid." - ) - - # brentq requires opposite signs at bounds - if np.sign(residual_min) == np.sign(residual_max): - # No root in interval - this happens when: - # 1. Supply > demand at all Pc (both positive) - need higher Pc but limited by tank pressure - # 2. Supply < demand at all Pc (both negative) - can't supply enough flow - - if residual_min > 0 and residual_max > 0: - # Supply > Demand at all Pc - # This means injector supplies more flow than combustion can demand - # Common causes: - # 1. Injector too large (orifice areas too big) - # 2. Throat too small (can't flow enough to balance supply) - # 3. Combustion efficiency too low (reduces demand) - # 4. Pc_max too conservative (we could go slightly higher) - - # Initialize skip_solve flag - skip_solve = False - - # Check if residual is small at Pc_max (near solution) - residual_tolerance = 0.1 # kg/s - accept if within 0.1 kg/s - - if residual_max < residual_tolerance: - # Residual is small - we're very close to solution - # Use Pc_max as solution with warning - # import warnings - # warnings.warn( - # f"Supply slightly > Demand at Pc_max. " - # f"Using Pc_max ({Pc_max/1e6:.2f} MPa) as solution. " - # f"Residual: {residual_max:.4f} kg/s. " - # f"Injector may be slightly oversized or throat slightly undersized." - # ) - # Skip to solution validation - use Pc_max as solution - Pc = Pc_max - success = True - # Skip the root finding loop below - skip_solve = True - else: - # Residual is significant - diagnose the issue - # Get diagnostics at Pc_max to understand supply/demand - try: - mdot_O_test, mdot_F_test, diag_test = flows( - P_tank_O, P_tank_F, Pc_max, self.config - ) - mdot_supply_test = mdot_O_test + mdot_F_test - - # Get demand at Pc_max - MR_test = mdot_O_test / mdot_F_test if mdot_F_test > 0 else np.inf - cg = ensure_chamber_geometry(self.config) - eps_default = cg.expansion_ratio - cea_props_test = self.cea_cache.eval(MR_test, Pc_max, 101325.0, eps_default) - cstar_ideal_test = cea_props_test.get("cstar_ideal", 0.0) - - # Build advanced_params for diagnostics - geometry_test = self._get_chamber_geometry() - advanced_params_test = { - "Pc": Pc_max, - "Tc": cea_props_test.get("Tc", DEFAULT_CHAMBER_TEMP_K), - "cstar_ideal": cstar_ideal_test, - "gamma": cea_props_test.get("gamma", DEFAULT_GAMMA_ND), - "R": cea_props_test.get("R", DEFAULT_GAS_CONST_J_KG_K), - "MR": MR_test, - "Ac": geometry_test["area_cross"], - "At": cg.A_throat, - "chamber_length": geometry_test["length"], - "Dinj": self._infer_injector_diameter(), - "m_dot_total": mdot_supply_test, - "spray_diagnostics": diag_test, - "turbulence_intensity": diag_test.get("turbulence_intensity_mix", DEFAULT_TURBULENCE_INTENSITY_ND), - "fuel_props": self._get_fuel_props(), - } - - # Calculate efficiency - eta_test = eta_cstar( - calculate_Lstar(cg.volume, cg.A_throat, Lstar_override=cg.Lstar), - self.config.combustion.efficiency, - diag_test.get("cooling_efficiency", 1.0), - advanced_params_test, - debug=debug if 'debug' in locals() else False, - ) - cstar_actual_test = eta_test * cstar_ideal_test - cg = ensure_chamber_geometry(self.config) - mdot_demand_test = (Pc_max * cg.A_throat) / cstar_actual_test if cstar_actual_test > 0 else np.inf - - # Calculate what Pc would balance (extrapolate) - # residual = supply - demand - # At Pc_max: residual = mdot_supply - mdot_demand - # Demand scales with Pc: mdot_demand ∝ Pc - # Supply decreases slightly with Pc: mdot_supply decreases as Pc increases - # Rough estimate: if we increase Pc by ΔPc, demand increases more than supply - - # Estimate required Pc (rough extrapolation) - # Assume linear relationship near Pc_max - if mdot_demand_test > 0 and mdot_supply_test > mdot_demand_test: - # We need more Pc to increase demand - # mdot_demand = Pc * At / c*, so Pc_needed = mdot_supply * c* / At - cg = ensure_chamber_geometry(self.config) - Pc_estimate = mdot_supply_test * cstar_actual_test / cg.A_throat - - raise ValueError( - f"No solution: Supply > Demand at all Pc. " - f"Residual at Pc_min: {residual_min:.4f} kg/s, at Pc_max: {residual_max:.4f} kg/s. " - f"\nDiagnostics at Pc_max ({Pc_max/1e6:.2f} MPa):" - f"\n - Supply: {mdot_supply_test:.4f} kg/s (mdot_O={mdot_O_test:.4f}, mdot_F={mdot_F_test:.4f})" - f"\n - Demand: {mdot_demand_test:.4f} kg/s (c*_actual={cstar_actual_test:.1f} m/s, At={cg.A_throat*1e6:.2f} mm²)" - f"\n - Estimated Pc needed: {Pc_estimate/1e6:.2f} MPa (vs Pc_max={Pc_max/1e6:.2f} MPa)" - f"\nPossible fixes:" - f"\n 1. Reduce injector orifice areas (currently oversized)" - f"\n 2. Increase throat area (currently undersized)" - f"\n 3. Increase tank pressures to allow higher Pc_max" - f"\n 4. Check combustion efficiency (low efficiency reduces demand)" - ) - else: - raise ValueError( - f"No solution: Supply > Demand at all Pc. " - f"Residual: [{residual_min:.4f}, {residual_max:.4f}] kg/s. " - f"Could not compute detailed diagnostics." - ) - except ValueError: - # Re-raise explicit ValueErrors from above - raise - except Exception as diag_e: - # Diagnostics failed - provide generic error - raise ValueError( - f"No solution: Supply > Demand at all Pc. " - f"Residual at bounds: [{residual_min:.4f}, {residual_max:.4f}] kg/s. " - f"Pc_max ({Pc_max/1e6:.2f} MPa) limited by tank pressure. " - f"Possible causes: Injector oversized, throat undersized, or combustion efficiency too low. " - f"Diagnostic error: {diag_e}" - ) - - else: - # Supply < Demand at all Pc (both negative) + + # The residual is not monotonic in Pc, so neither an endpoint sign test nor a Brent + # over the whole window is safe: the operating point is the HIGHEST-Pc root, and the + # crossing nearest the floor (if any) is the barely-flowing spurious one. Locate the + # top-most sign change and bracket Brent inside it. + lo, hi = self._highest_sign_change(residual_func, Pc_min, Pc_max, residual_min, residual_max) + if lo is None: + if residual_min > 0 and residual_max > 0: + self._raise_supply_exceeds_demand(P_tank_O, P_tank_F, Pc_max, residual_min, residual_max, debug) raise ValueError( f"No solution: Supply < Demand at all Pc. " f"Residual at bounds: [{residual_min:.4f}, {residual_max:.4f}] kg/s. " f"Insufficient mass flow. Check tank pressures and injector geometry." ) - - # Check if we already have a solution (from small residual case above) - # skip_solve is defined in the if-else block above, default to False if not set - if 'skip_solve' not in locals(): - skip_solve = False - - if not skip_solve: - # Validate bracket before solving - bracket_check = NumericalStability.check_bracket(residual_func, Pc_min, Pc_max) - if not bracket_check.passed: - raise ValueError(f"Invalid bracket for root finding: {bracket_check.message}") - - # Track convergence history for diagnostics - convergence_history = [] - - # Enhanced residual function with convergence tracking + def tracked_residual_func(Pc): res = residual_func(Pc) convergence_history.append(float(res)) return res - - # Solve using bracketed secant (brentq) - safe and robust + try: if self.config.solver.method == "brentq": Pc, result = brentq( tracked_residual_func, - Pc_min, - Pc_max, + lo, + hi, xtol=self.config.solver.tolerance, rtol=self.config.solver.tolerance * 1e-3, # Relative tolerance maxiter=self.config.solver.max_iterations, full_output=True ) success = result.converged - - # Validate convergence - conv_check = NumericalStability.check_convergence( - convergence_history, - self.config.solver.tolerance, - min_iterations=3 - ) - if not conv_check.passed and conv_check.severity == "error": - raise RuntimeError(f"Convergence validation failed: {conv_check.message}") - else: - # Fallback to Newton's method (less robust) + # Newton from the middle of the bracket (less robust; kept for configs that ask) Pc = newton( tracked_residual_func, - Pc_guess, + 0.5 * (lo + hi), tol=self.config.solver.tolerance, maxiter=self.config.solver.max_iterations ) success = True - - # Validate convergence for Newton - conv_check = NumericalStability.check_convergence( - convergence_history, - self.config.solver.tolerance, - min_iterations=3 - ) - if not conv_check.passed and conv_check.severity == "error": - raise RuntimeError(f"Convergence validation failed: {conv_check.message}") - - except ValueError as e: - # Re-raise ValueError (bracket issues, etc.) + conv_check = NumericalStability.check_convergence( + convergence_history, + self.config.solver.tolerance, + min_iterations=3 + ) + if not conv_check.passed and conv_check.severity == "error": + raise RuntimeError(f"Convergence validation failed: {conv_check.message}") + except ValueError: raise except Exception as e: raise RuntimeError(f"Chamber pressure solver failed: {e}") - else: - # We're using Pc_max as solution (small residual case) - # Already set Pc = Pc_max and success = True above - convergence_history = [residual_max] # Store for diagnostics - + # Validate solution Pc_val = float(Pc) if not np.isfinite(Pc_val): @@ -623,46 +543,6 @@ def tracked_residual_func(Pc): with_profile=not getattr(self, "_silent", False), ) - # Calculate reaction progress through chamber (if finite-rate chemistry enabled) - reaction_progress = None - if getattr(self.config.combustion.efficiency, 'use_finite_rate_chemistry', True): - try: - from engine.pipeline.reaction_chemistry import calculate_chamber_reaction_progress - - # Pass spray diagnostics if available for better evaporation/mixing estimates - spray_diagnostics = closure_diag if closure_diag else None - - # Use conservative "Worst of Both Worlds" temperatures: - # Tc (Ideal) for residence time (shorter time is conservative) - # effective_Tc (Actual) for kinetics (slower chemistry is conservative) - reaction_progress = calculate_chamber_reaction_progress( - current_Lstar, - Pc_val, - cea_props["Tc"], # Ideal Tc (Residence Time) - cea_props["cstar_ideal"], - cea_props["gamma"], - cea_props["R"], - MR, - self.config, - spray_diagnostics=spray_diagnostics, - Tc_kinetics=effective_Tc, # Actual Tc (Kinetics) - ) - except Exception as e: - # Don't silently fail - raise error or log warning - import warnings - warnings.warn(f"Reaction progress calculation failed: {e}. This may indicate invalid engine conditions.") - # Minimal fallback - but indicate uncertainty - # CRITICAL FIX: Correct residence time formula - rho_chamber = Pc_val / (cea_props["R"] * cea_props["Tc"]) if cea_props["R"] > 0 and cea_props["Tc"] > 0 else 1.0 - # Use actual mdot_total from closure (calculated above) - cg = ensure_chamber_geometry(self.config) - tau_residence_correct = current_Lstar * rho_chamber * cg.A_throat / mdot_total if mdot_total > 0 else 0.001 - reaction_progress = { - "progress_throat": 1.0, # Assume equilibrium - "tau_residence": tau_residence_correct, - "calculation_failed": True, - } - # Extract and validate mixture diagnostics (diagnostics-only, no efficiency impact) # Enable mixture coupling diagnostics if configured eff_cfg = self.config.combustion.efficiency diff --git a/EngineDesign/engine/core/runner.py b/EngineDesign/engine/core/runner.py index a35fba084..1ec113081 100644 --- a/EngineDesign/engine/core/runner.py +++ b/EngineDesign/engine/core/runner.py @@ -472,7 +472,7 @@ def log_info(msg): "stability_state": "unstable", "stability_score": 0.0, "is_stable": False, - "chugging": {"frequency": 0.0, "stability_margin": 0.0, "stability_index": 0.0, "period": 0.0, "tau_residence": 0.0, "Lstar": 0.0}, + "chugging": {"frequency": 0.0, "stability_margin": 0.0, "period": 0.0, "tau_residence": 0.0, "Lstar": 0.0}, "acoustic": {"stability_margin": 0.0, "modes": {}, "longitudinal_modes": [], "transverse_modes": [], "sound_speed": 0.0}, "feed_system": {"pogo_frequency": 0.0, "surge_frequency": 0.0, "water_hammer_margin": 0.0, "stability_margin": 0.0, "sound_speed": 0.0}, "mode_coupling": [], diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py index 2fc34bd4b..c1d84ed70 100644 --- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py +++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py @@ -1664,7 +1664,9 @@ def _layer1_apply_chamber_geometry_to_config( L_chamber = V_chamber / A_chamber if A_chamber > 0 else 0.2 L_cylindrical = max(L_chamber * 0.5, 0.05) - L_chamber = np.clip(L_chamber, 0.005, 1.0) + # Positivity guard only. The old clip also capped the chamber at 1.0 m, a size limit no + # requirement asked for; engine length is constrained by max_engine_length elsewhere. + L_chamber = max(float(L_chamber), 0.005) if config.chamber_geometry is None: cg = ensure_chamber_geometry(config) @@ -1739,6 +1741,115 @@ def _layer1_apply_chamber_geometry_to_config( _DERIVE_AT_SLOPE_MAX = 1.20 +def _layer1_stamp_design_point(config, performance, logger=None) -> None: + """Write the ACHIEVED operating point into ``chamber_geometry.design_*``. + + ``design_MR`` / ``design_pressure`` / ``design_thrust`` are supposed to describe the design + the config represents. Nothing ever wrote them: `config_schemas` builds them with + ``getattr(chamber, 'design_MR', 2.55)``, so an optimised config carried whatever the template + started with. Observed on a real emitted design -- MR 2.55 / 350 psi / 7000 N stamped on an + engine actually solved at O/F ~1.68 / 420 psi / 7200 N. + + That is not cosmetic: ``backend/routers/geometry.py`` reads ``design_MR`` and feeds it + straight into ``solve_chamber_geometry_with_cea``, so the Chamber Geometry tab drew the + contour at the stale mixture ratio -- and 2.55 sits OUTSIDE the shipped CEA cache range + (``MR_range: [1.0, 2.5]``), i.e. extrapolating past the table edge. + + Warns rather than raises when the achieved MR falls outside the cache range: the design is + still real, but anything reading design_MR against that cache is extrapolating. + """ + cg = getattr(config, "chamber_geometry", None) + if cg is None or not isinstance(performance, dict): + return + + def _finite_pos(value): + try: + v = float(value) + except (TypeError, ValueError): + return None + return v if (np.isfinite(v) and v > 0) else None + + mr = _finite_pos(performance.get("MR")) + pc = _finite_pos(performance.get("Pc")) + thrust = _finite_pos(performance.get("F")) + + if mr is not None: + cg.design_MR = mr + if pc is not None: + cg.design_pressure = pc + if thrust is not None: + cg.design_thrust = thrust + + # Keep the legacy mirror in step -- some readers still fall back to config.chamber. + legacy = getattr(config, "chamber", None) + if legacy is not None: + if mr is not None and hasattr(legacy, "design_MR"): + legacy.design_MR = mr + if pc is not None and hasattr(legacy, "design_pressure"): + legacy.design_pressure = pc + if thrust is not None and hasattr(legacy, "design_thrust"): + legacy.design_thrust = thrust + + if mr is None or logger is None: + return + try: + mr_range = config.combustion.cea.MR_range + lo, hi = float(mr_range[0]), float(mr_range[1]) + except (AttributeError, TypeError, ValueError, IndexError): + return + if not (lo <= mr <= hi): + logger.warning( + "design_MR %.4f is outside the CEA cache MR_range [%.2f, %.2f]; anything reading " + "it against that cache is extrapolating past the table edge.", mr, lo, hi + ) + + + +def _layer1_warn_stale_pressure_curves(config, logger=None, tol_psi: float = 1.0) -> None: + """Flag Layer-2 pressure curves that no longer match the tank pressures Layer 1 just set. + + The initial tank pressure exists twice, owned by different layers and never reconciled: + Layer 1 writes ``lox_tank/fuel_tank.initial_pressure_psi``; Layer 2 writes + ``pressure_curves.initial_lox/fuel_pressure_pa``. Re-running Layer 1 moves the tanks and + silently leaves the curves describing the previous design -- observed 11.3 psi out on the + LOX side and 24.9 psi on the fuel side of a real emitted config. + + This matters beyond EngineDesign. Tank pressure is the UPSTREAM BOUNDARY CONDITION for the + feed-system twin (docs/adr/0001), which EngineDesign's optimizer will import directly for + Layer X. Two disagreeing values for one boundary condition is exactly the kind of thing that + silently poisons a twin, so say so loudly rather than letting it cross the boundary. + + Detection only -- which layer should win is a design call, not something to guess here. + """ + if logger is None: + return + curves = getattr(config, "pressure_curves", None) + if curves is None: + return + PSI = 6894.76 + for tank_attr, curve_attr, label in ( + ("lox_tank", "initial_lox_pressure_pa", "LOX"), + ("fuel_tank", "initial_fuel_pressure_pa", "fuel"), + ): + tank = getattr(config, tank_attr, None) + if tank is None: + continue + try: + tank_psi = float(getattr(tank, "initial_pressure_psi", float("nan"))) + curve_psi = float(getattr(curves, curve_attr, float("nan"))) / PSI + except (TypeError, ValueError): + continue + if not (np.isfinite(tank_psi) and np.isfinite(curve_psi)): + continue + if abs(tank_psi - curve_psi) > tol_psi: + logger.warning( + "%s tank pressure disagrees with the Layer-2 pressure curve: tank %.1f psi vs " + "curve start %.1f psi (%.1f psi apart). The curves predate this Layer 1 run; " + "re-run Layer 2 before trusting them or anything downstream of them.", + label, tank_psi, curve_psi, abs(tank_psi - curve_psi), + ) + + def _layer1_eps_for_exit_pressure(Pc_Pa: float, gamma: float, Pe_Pa: float): """Expansion ratio that puts the exit plane exactly at ``Pe_Pa``. @@ -2575,7 +2686,7 @@ def _config_to_dict(config: PintleEngineConfig) -> dict: Uses pydantic's dict() method if available, otherwise falls back to __dict__. """ - return config.dict() if hasattr(config, 'dict') else config.__dict__ + return config.model_dump() if hasattr(config, 'model_dump') else config.__dict__ def _dict_to_config(config_dict: dict) -> PintleEngineConfig: @@ -2732,7 +2843,16 @@ def _snap_integer_dims(x: np.ndarray, integer_indices: list) -> np.ndarray: def _get_num_workers(config_obj) -> int: - """Get number of workers from config or default to cpu_count - 1.""" + """Get number of workers from config or default to cpu_count - 1. + + ``ED_L1_WORKERS`` overrides both (``1`` runs every candidate in-process, which is what a + debugger or an infeasibility trace needs).""" + env = os.environ.get("ED_L1_WORKERS") + if env: + try: + return max(1, int(env)) + except ValueError: + pass if hasattr(config_obj, 'optimizer') and hasattr(config_obj.optimizer, 'num_workers'): num_workers = config_obj.optimizer.num_workers else: @@ -2877,6 +2997,18 @@ def _apply_x_to_worker_config_inplace(x: np.ndarray, config: PintleEngineConfig, config.fuel_tank.initial_pressure_psi = _pf +# Infeasibility trace: set ED_L1_TRACE_INFEAS=1 (with ED_L1_WORKERS=1 so candidates run in-process) +# and each objective evaluation appends {checkpoint: running infeasibility score} here -- the only +# way to see WHICH gate keeps a run infeasible when every candidate returns the 1e6 floor. +_INFEAS_TRACE: List[Dict[str, float]] = [] +_INFEAS_TRACE_ON = bool(os.environ.get("ED_L1_TRACE_INFEAS")) + + +def _infeas_trace(entry: Optional[Dict[str, float]], label: str, value: float) -> None: + if entry is not None: + entry[label] = float(value) + + def _compute_objective_value(result: dict, x: np.ndarray, requirements: dict, constants: dict) -> float: """Compute objective value from evaluation result. @@ -2987,6 +3119,9 @@ def _compute_objective_value(result: dict, x: np.ndarray, requirements: dict, co P_F_ratio = P_F_psi / max_fuel_P_psi if max_fuel_P_psi > 0 else 0.0 infeasibility_score = 0.0 + _tr = {} if _INFEAS_TRACE_ON else None + if _tr is not None: + _INFEAS_TRACE.append(_tr) if A_chamber_check > 0 and A_throat_check > 0: contraction_ratio_check = A_chamber_check / A_throat_check @@ -3039,6 +3174,7 @@ def _compute_objective_value(result: dict, x: np.ndarray, requirements: dict, co A_throat_check=A_throat_check, ) + _infeas_trace(_tr, "geometry", infeasibility_score) # --- Evaluation Results --- eval_success = result.get('success', False) if isinstance(result, dict) else False # Runner.evaluate typically omits success; infer from finite thrust/Pc when absent @@ -3150,6 +3286,7 @@ def _compute_objective_value(result: dict, x: np.ndarray, requirements: dict, co infeasibility_score += max(0.0, (effective_margin - chugging_margin) / effective_margin) ** 2 infeasibility_score += max(0.0, (effective_margin - acoustic_margin) / effective_margin) ** 2 infeasibility_score += max(0.0, (effective_margin - feed_margin) / effective_margin) ** 2 + _infeas_trace(_tr, "stability", infeasibility_score) # Regularization: Cf band def _hinge_band(val, lo, hi, scale=1.0): @@ -3327,6 +3464,8 @@ def _hinge_band(val, lo, hi, scale=1.0): max_outward_deg=float(constants.get("layer1_resultant_tilt_max_deg", 0.0)), scale_deg=float(constants.get("layer1_resultant_tilt_scale_deg", 2.0)), ) + _infeas_trace(_tr, "wall_tilt", infeasibility_score) + _infeas_trace(_tr, "tilt_deg", _tilt) momentum_term = _impinging_momentum_asymmetric_squared( R_val, wall_side_multiplier=float( @@ -3865,6 +4004,60 @@ def _layer1_emit_objective_plot_point( pass +def _layer1_infeasibility_reason(runner, x, requirements: dict, constants: dict) -> Optional[str]: + """One sentence on WHICH hard constraint held the best candidate out of the feasible set. + + A run whose every candidate sat on the 1e6 infeasibility floor used to end with + "objective inf" and "Validation failed", which told the user nothing. Re-evaluate the best + design in-process with the infeasibility trace on and name the dominant gate. + """ + global _INFEAS_TRACE_ON + try: + idx_P_O = 11 if constants.get("injector_type") == "impinging" else 8 + P_O = float(x[idx_P_O]) * 6894.76 + P_F = float(x[idx_P_O + 1]) * 6894.76 + result = runner.evaluate(P_O, P_F, silent=True) + except Exception as e: + return f"No feasible design: the best candidate could not even be evaluated ({type(e).__name__}: {str(e)[:120]})." + prev, n0 = _INFEAS_TRACE_ON, len(_INFEAS_TRACE) + _INFEAS_TRACE_ON = True + try: + _compute_objective_value(result, np.asarray(x, dtype=float), requirements, constants) + except Exception as e: + return f"No feasible design: objective re-evaluation failed ({type(e).__name__})." + finally: + _INFEAS_TRACE_ON = prev + if len(_INFEAS_TRACE) <= n0: + return None + tr = _INFEAS_TRACE.pop() + geom = float(tr.get("geometry", 0.0)) + stab = float(tr.get("stability", geom)) - geom + wall = float(tr.get("wall_tilt", tr.get("stability", geom))) - float(tr.get("stability", geom)) + parts = [] + if wall > 0: + tilt = tr.get("tilt_deg", float("nan")) + lim = float(constants.get("layer1_resultant_tilt_max_deg", 0.0)) + parts.append((wall, f"the spray resultant tilts {tilt:+.1f} deg outward toward the wall (limit {lim:g} deg) -- " + "lower the oxidizer jet angle, raise the fuel jet angle, or widen the angle bands")) + if stab > 0: + parts.append((stab, "a stability gate (minimum score / margins) is not met -- lower min_stability_score or stiffen the injector")) + if geom > 0: + parts.append((geom, "injector packing, flow capacity, or chamber proportions violate a hard limit -- widen the chamber OD or the jet bounds")) + if not parts: + # Re-evaluated in isolation the best design passes every gate: the search is stuck ON a + # constraint boundary and CMA's samples keep landing a hair outside it. For a doublet that + # is almost always the spray-resultant tilt limit, which defaults to exactly 0 deg outward. + lim = float(constants.get("layer1_resultant_tilt_max_deg", 0.0)) + if constants.get("injector_type") == "impinging": + return ("No candidate cleared every hard constraint, yet the best design re-evaluates as feasible " + f"on its own: the search is pinned on the spray-resultant tilt limit ({lim:g} deg outward). " + "Re-run, or allow a degree of outward tilt (layer1_resultant_tilt_max_deg) if the liner can take it.") + return ("No candidate cleared every hard constraint, yet the best design re-evaluates as feasible on its " + "own: the search is pinned on a constraint boundary. Re-run, or relax the tightest gate slightly.") + parts.sort(key=lambda t: -t[0]) + return "No candidate cleared every hard constraint. On the best one, " + parts[0][1] + "." + + def run_layer1_global_search( objective: Callable[[np.ndarray], float], bounds: list, @@ -3975,6 +4168,29 @@ def wrapped_obj(v: np.ndarray) -> float: return best_x +def _layer1_check_of_target_in_cea_range(config_obj: Any, optimal_of: Any) -> None: + """Refuse a target O/F the propellant's CEA table cannot evaluate. + + A propellant switch keeps the previous design target, so an ethalox target of 1.4 left + behind on a methalox config used to run a full optimization against a table that stops at + 2.4 -- the mixture ratio pinned at the table edge and the run returned a huge objective with + nothing to say why. Fail before any work is done, with the fix in the message. + """ + try: + mr_range = config_obj.combustion.cea.MR_range + lo, hi = float(mr_range[0]), float(mr_range[1]) + of = float(optimal_of) + except (AttributeError, TypeError, ValueError, IndexError): + return + if not (np.isfinite(of) and lo <= of <= hi): + preset = getattr(config_obj, "propellant_preset", None) or "this propellant" + raise ValueError( + f"Design target O/F {of:.2f} is outside the CEA table for {preset} " + f"(MR_range [{lo:.2f}, {hi:.2f}]). Set optimal_of_ratio inside that range in Design " + f"Requirements -- switching propellant keeps the previous target." + ) + + def run_layer1_optimization( config_obj: PintleEngineConfig, runner: PintleEngineRunner, @@ -4075,6 +4291,7 @@ def check_stop(): # Extract requirements target_thrust = requirements.get("target_thrust", 7000.0) optimal_of = requirements.get("optimal_of_ratio", 2.3) + _layer1_check_of_target_in_cea_range(config_obj, optimal_of) min_stability = float(requirements.get("min_stability_margin", _LAYER1_DEFAULT_MIN_STABILITY_MARGIN)) def _resolve_Lstar_bounds_from_req_and_config() -> Tuple[float, float]: @@ -7697,6 +7914,16 @@ def _validation_evaluate_or_bundle( optimized_config_runner.graphite_insert.enabled = False optimized_runner = PintleEngineRunner(optimized_config_runner) + + # When nothing was feasible, say which gate held the best candidate out (see the helper). + infeasible_reason = None + try: + if best_x is not None and not _layer1_feasible_scalar_objective(float(opt_state.get("best_objective", float("inf")))): + infeasible_reason = _layer1_infeasibility_reason(optimized_runner, best_x, requirements, constants_dict) + if infeasible_reason and log_status: + log_status("warning", infeasible_reason) + except Exception: + infeasible_reason = None # Use stored validation results if available if "best_results_for_validation" in opt_state and opt_state["best_results_for_validation"] is not None: @@ -8402,6 +8629,7 @@ def _as_finite_float_or_nan(v: Any) -> float: else {} ), "primary_relative_residual": _prim_rel, + "infeasible_reason": infeasible_reason, }, "exit_pressure_targeting": { "target_P_exit": target_P_exit, # Atmospheric pressure from environment config (GPS/GFS-derived) @@ -8563,7 +8791,11 @@ def _as_finite_float_or_nan(v: Any) -> float: layer1_logger.handlers.clear() update_progress("Layer 1: Complete", 1.0, "Layer 1 optimization complete!") - + + # Stamp the ACHIEVED operating point onto the config we are about to hand back, so + # chamber_geometry.design_* describes this engine rather than whatever template it came from. + _layer1_stamp_design_point(optimized_config, final_performance, layer1_logger) + _layer1_warn_stale_pressure_curves(optimized_config, layer1_logger) return optimized_config, results diff --git a/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py b/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py deleted file mode 100644 index 7caf1f396..000000000 --- a/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py +++ /dev/null @@ -1,495 +0,0 @@ -"""Comprehensive geometry sizing and visualization for chamber, throat, and ablative. - -This module provides: -1. Optimal sizing of ablative and throat together -2. Combined visualization (plot + DXF) showing all three components -3. Robust solver with error handling and validation -""" - -from __future__ import annotations - -from typing import Dict, Any, Optional, Tuple, List -import numpy as np -import matplotlib.pyplot as plt -from matplotlib.patches import Rectangle, Circle, FancyBboxPatch -from matplotlib.collections import PatchCollection -import io - -from .config_schemas import ( - PintleEngineConfig, - AblativeCoolingConfig, - GraphiteInsertConfig, - StainlessSteelCaseConfig, - ensure_chamber_geometry, -) -from engine.pipeline.thermal.ablative_sizing import size_ablative_system -from engine.pipeline.thermal.graphite_geometry import size_graphite_insert as size_graphite_geom -from engine.core.chamber_profiles import calculate_complete_chamber_geometry - - -def size_complete_geometry( - config: PintleEngineConfig, - Pc: float, - MR: float, - Tc: float, - gamma: float, - R: float, - burn_time: float, - chamber_heat_flux: float, - throat_heat_flux_multiplier: float = 1.5, -) -> Dict[str, Any]: - """ - Size complete geometry: chamber (ablative), throat (graphite), and all components together. - - This function: - 1. Sizes ablative liner for chamber - 2. Sizes graphite insert for throat (with zero recession) - 3. Validates all sizing meets requirements - 4. Returns optimal geometry configuration - - Parameters: - ----------- - config : PintleEngineConfig - Engine configuration - Pc : float - Chamber pressure [Pa] - MR : float - Mixture ratio - Tc : float - Chamber temperature [K] - gamma : float - Specific heat ratio - R : float - Gas constant [J/(kg·K)] - burn_time : float - Burn time [s] - chamber_heat_flux : float - Chamber heat flux [W/m²] - throat_heat_flux_multiplier : float - Multiplier for throat heat flux vs chamber (default 1.5) - - Returns: - -------- - sizing_results : dict - Complete sizing results including: - - ablative_sizing: Ablative thickness and properties - - graphite_sizing: Graphite insert sizing - - geometry: Complete geometry profile - - validation: Validation results - - optimal: Optimal configuration selected - """ - results = { - "ablative_sizing": None, - "graphite_sizing": None, - "geometry": None, - "validation": {}, - "optimal": {}, - } - - # 1. Size ablative system for chamber - if config.ablative_cooling and config.ablative_cooling.enabled: - ablative_sizing = size_ablative_system( - heat_flux=chamber_heat_flux, - burn_time=burn_time, - ablative_config=config.ablative_cooling, - backface_temp_limit=500.0, # K - Max for stainless steel - T_hot_gas=Tc, - h_hot_gas=5000.0, # W/(m²·K) - Typical for rocket chambers - q_rad_hot=0.0, # Negligible for LOX/RP-1 - ) - results["ablative_sizing"] = ablative_sizing - else: - results["ablative_sizing"] = {"required_thickness": 0.0, "meets_requirements": True} - - # 2. Size graphite insert for throat (with ZERO recession - that's its purpose) - if config.graphite_insert and config.graphite_insert.enabled: - throat_heat_flux = chamber_heat_flux * throat_heat_flux_multiplier - - # Get throat conditions - # Surface temperature estimate (throat is hottest) - surface_temp_throat = Tc * 0.85 # Conservative estimate - - # CRITICAL: Graphite recession should be ZERO for sizing - # The whole point is that graphite doesn't ablate - it keeps throat constant - # Use a small value only for sizing calculations (material allowance), not runtime - recession_rate_for_sizing = 1e-8 # Negligible - graphite doesn't ablate - - # Get throat diameter from config or calculate - cg = ensure_chamber_geometry(config) - if cg.A_throat: - A_throat = cg.A_throat - D_throat = np.sqrt(4.0 * A_throat / np.pi) - else: - # Estimate from typical expansion ratio - D_throat = 0.020 # 20 mm default - - # Use graphite_geometry.size_graphite_insert (returns GraphiteInsertSizing dataclass) - graphite_sizing_obj = size_graphite_geom( - peak_heat_flux=throat_heat_flux, - surface_temperature=surface_temp_throat, - recession_rate=recession_rate_for_sizing, # Negligible - graphite doesn't ablate - burn_time=burn_time, - thermal_conductivity=config.graphite_insert.thermal_conductivity, - backface_temperature_max=500.0, # K - Max for stainless steel - throat_diameter=D_throat, - density=config.graphite_insert.material_density, - specific_heat=config.graphite_insert.specific_heat, - mechanical_thickness=0.001, # 1 mm - safety_factor=0.3, # 30% - transient=True, - ) - # Convert to dict for compatibility - graphite_sizing = graphite_sizing_obj.to_dict() - graphite_sizing["meets_requirements"] = not graphite_sizing_obj.throat_area_change_excessive - results["graphite_sizing"] = graphite_sizing - else: - results["graphite_sizing"] = {"initial_thickness": 0.0, "meets_requirements": True} - - # 3. Calculate complete geometry - # Get geometry from chamber_geometry - cg = ensure_chamber_geometry(config) - V_chamber = cg.volume - A_throat = cg.A_throat - L_chamber = cg.length if cg.length else (cg.volume / cg.A_throat if cg.A_throat and cg.A_throat > 0 else 0.18) - - # Calculate diameters - if L_chamber > 0: - D_chamber_initial = np.sqrt(4.0 * V_chamber / (np.pi * L_chamber)) - else: - D_chamber_initial = np.sqrt(4.0 * V_chamber / np.pi) # Assume cylindrical - D_throat_initial = np.sqrt(4.0 * A_throat / np.pi) if A_throat > 0 else 0.020 - else: - # Fallback estimates - V_chamber = 0.001 # 1 L - A_throat = np.pi * (0.010) ** 2 # 20 mm diameter - L_chamber = 0.1 # 10 cm - D_chamber_initial = 0.05 # 50 mm - D_throat_initial = 0.020 # 20 mm - - geometry = calculate_complete_chamber_geometry( - V_chamber=V_chamber, - A_throat=A_throat, - L_chamber=L_chamber, - D_chamber_initial=D_chamber_initial, - D_throat_initial=D_throat_initial, - ablative_config=config.ablative_cooling if config.ablative_cooling else None, - graphite_config=config.graphite_insert if config.graphite_insert else None, - stainless_config=config.stainless_steel_case if hasattr(config, "stainless_steel_case") else None, - recession_chamber=0.0, # Initial state - recession_graphite=0.0, # Graphite doesn't recede - n_points=100, - ) - results["geometry"] = geometry - - # 4. Validate sizing - validation = { - "ablative_meets_requirements": results["ablative_sizing"].get("meets_requirements", True), - "graphite_meets_requirements": results["graphite_sizing"].get("meets_requirements", True), - "all_valid": True, - "warnings": [], - } - - if config.ablative_cooling and config.ablative_cooling.enabled: - if not validation["ablative_meets_requirements"]: - validation["warnings"].append("Ablative backface temperature exceeds limit") - validation["all_valid"] = False - - if config.graphite_insert and config.graphite_insert.enabled: - if not validation["graphite_meets_requirements"]: - validation["warnings"].append("Graphite backface temperature exceeds limit") - validation["all_valid"] = False - - # Check graphite thickness is reasonable - graphite_thickness = results["graphite_sizing"].get("initial_thickness", 0.0) - if graphite_thickness < 0.001: # Less than 1 mm - validation["warnings"].append("Graphite thickness is very small - may not provide adequate protection") - if graphite_thickness > 0.010: # More than 10 mm - validation["warnings"].append("Graphite thickness is very large - consider optimization") - - results["validation"] = validation - - # 5. Select optimal configuration - optimal = { - "ablative_thickness": results["ablative_sizing"].get("required_thickness", 0.0), - "graphite_thickness": results["graphite_sizing"].get("initial_thickness", 0.0), - "throat_diameter": D_throat_initial, - "chamber_diameter": D_chamber_initial, - "chamber_length": L_chamber, - "total_mass": 0.0, # Could calculate if needed - "meets_all_requirements": validation["all_valid"], - } - - # Calculate total mass (rough estimate) - if config.ablative_cooling and config.ablative_cooling.enabled: - ablative_density = config.ablative_cooling.material_density - ablative_volume = np.pi * L_chamber * ( - (D_chamber_initial / 2.0 + optimal["ablative_thickness"]) ** 2 - - (D_chamber_initial / 2.0) ** 2 - ) - optimal["ablative_mass"] = ablative_density * ablative_volume - else: - optimal["ablative_mass"] = 0.0 - - if config.graphite_insert and config.graphite_insert.enabled: - graphite_density = config.graphite_insert.material_density - # Approximate graphite as cylinder around throat - graphite_length = optimal["graphite_thickness"] * 2.0 # Rough estimate - graphite_volume = np.pi * graphite_length * ( - (D_throat_initial / 2.0 + optimal["graphite_thickness"]) ** 2 - - (D_throat_initial / 2.0) ** 2 - ) - optimal["graphite_mass"] = graphite_density * graphite_volume - else: - optimal["graphite_mass"] = 0.0 - - optimal["total_mass"] = optimal["ablative_mass"] + optimal["graphite_mass"] - - results["optimal"] = optimal - - return results - - -def plot_complete_geometry( - sizing_results: Dict[str, Any], - config: PintleEngineConfig, - save_path: Optional[str] = None, - show_graphite: bool = True, - show_ablative: bool = True, - show_stainless: bool = True, - use_plotly: bool = True, -) -> Tuple[Any, bytes]: - """ - Create comprehensive plot showing chamber, throat, ablative, and graphite all together. - - Parameters: - ----------- - sizing_results : dict - Results from size_complete_geometry() - config : PintleEngineConfig - Engine configuration - save_path : str, optional - Path to save figure (if None, not saved) - show_graphite : bool - Show graphite insert (default True) - show_ablative : bool - Show ablative liner (default True) - show_stainless : bool - Show stainless steel case (default True) - use_plotly : bool - Use Plotly for interactive plots (default True), otherwise matplotlib - - Returns: - -------- - fig : plotly.Figure or matplotlib.Figure - Figure object - dxf_bytes : bytes - DXF file bytes (placeholder - would need dxf library) - """ - geometry = sizing_results["geometry"] - positions = np.array(geometry["positions"]) - - if use_plotly: - import plotly.graph_objects as go - - fig = go.Figure() - - # Chamber gas boundary (inner surface) - orange - D_gas = np.array(geometry.get("D_gas_chamber", geometry.get("D_chamber_current", np.zeros_like(positions)))) - if isinstance(D_gas, (int, float)) or len(D_gas) == 1: - D_gas = np.full_like(positions, float(D_gas) if isinstance(D_gas, (int, float)) else D_gas[0]) - D_gas_radius = D_gas / 2.0 - - fig.add_trace(go.Scatter( - x=positions, - y=D_gas_radius, - mode='lines', - name='Gas Boundary (Chamber)', - line=dict(color='orange', width=3), - fill='tozeroy', - fillcolor='rgba(255, 165, 0, 0.1)', - )) - fig.add_trace(go.Scatter( - x=positions, - y=-D_gas_radius, - mode='lines', - name='Gas Boundary (Lower)', - line=dict(color='orange', width=3), - fill='tozeroy', - fillcolor='rgba(255, 165, 0, 0.1)', - showlegend=False, - )) - - # Ablative layer - brown dashed - if show_ablative and geometry.get("ablative_thickness", [0.0])[0] > 0: - D_ablative = np.array(geometry.get("D_ablative_outer", D_gas)) - if isinstance(D_ablative, (int, float)) or len(D_ablative) == 1: - D_ablative = np.full_like(positions, float(D_ablative) if isinstance(D_ablative, (int, float)) else D_ablative[0]) - D_ablative_radius = D_ablative / 2.0 - - fig.add_trace(go.Scatter( - x=positions, - y=D_ablative_radius, - mode='lines', - name='Phenolic Ablator (Outer)', - line=dict(color='brown', width=2, dash='dash'), - fill='tonexty', - fillcolor='rgba(139, 69, 19, 0.3)', - )) - fig.add_trace(go.Scatter( - x=positions, - y=-D_ablative_radius, - mode='lines', - name='Phenolic Ablator (Lower)', - line=dict(color='brown', width=2, dash='dash'), - fill='tonexty', - fillcolor='rgba(139, 69, 19, 0.3)', - showlegend=False, - )) - - # Stainless steel case - gray dotted - if show_stainless and geometry.get("stainless_thickness", 0.0) > 0: - D_stainless = np.array(geometry.get("D_stainless_outer", D_gas)) - if isinstance(D_stainless, (int, float)) or len(D_stainless) == 1: - D_stainless = np.full_like(positions, float(D_stainless) if isinstance(D_stainless, (int, float)) else D_stainless[0]) - D_stainless_radius = D_stainless / 2.0 - - fig.add_trace(go.Scatter( - x=positions, - y=D_stainless_radius, - mode='lines', - name='Stainless Steel Case', - line=dict(color='gray', width=2, dash='dot'), - fill='tonexty', - fillcolor='rgba(128, 128, 128, 0.2)', - )) - fig.add_trace(go.Scatter( - x=positions, - y=-D_stainless_radius, - mode='lines', - name='Stainless Steel (Lower)', - line=dict(color='gray', width=2, dash='dot'), - fill='tonexty', - fillcolor='rgba(128, 128, 128, 0.2)', - showlegend=False, - )) - - # Throat region with graphite - ONLY at throat, not entire chamber - if show_graphite and config.graphite_insert and config.graphite_insert.enabled: - D_throat = geometry.get("D_throat_current", 0.020) - D_graphite_outer = geometry.get("D_graphite_outer", D_throat) - throat_pos = positions[-1] if len(positions) > 0 else 0.0 - - # Graphite axial length (typically 0.75 * D_throat on each side) - D_throat_diameter = D_throat - graphite_axial_half_length = getattr(config.graphite_insert, 'axial_half_length', 0.75 * D_throat_diameter) - if graphite_axial_half_length <= 0: - graphite_axial_half_length = 0.75 * D_throat_diameter - - # Graphite region (ONLY around throat) - graphite_start = max(throat_pos - graphite_axial_half_length, positions[0]) - graphite_end = min(throat_pos + graphite_axial_half_length, positions[-1]) - graphite_positions = np.linspace(graphite_start, graphite_end, 30) - D_graphite_radius = D_graphite_outer / 2.0 - D_throat_radius = D_throat / 2.0 - - # Graphite outer boundary (black, ONLY in throat region) - fig.add_trace(go.Scatter( - x=graphite_positions, - y=[D_graphite_radius] * len(graphite_positions), - mode='lines', - name='Graphite Insert', - line=dict(color='black', width=3), - )) - fig.add_trace(go.Scatter( - x=graphite_positions, - y=[-D_graphite_radius] * len(graphite_positions), - mode='lines', - name='Graphite Insert (Lower)', - line=dict(color='black', width=3), - showlegend=False, - )) - - # Throat (red marker at minimum diameter) - fig.add_trace(go.Scatter( - x=[throat_pos], - y=[D_throat_radius], - mode='markers', - marker=dict(size=12, color='red', symbol='circle', line=dict(width=2, color='darkred')), - name='Throat', - showlegend=True, - )) - fig.add_trace(go.Scatter( - x=[throat_pos], - y=[-D_throat_radius], - mode='markers', - marker=dict(size=12, color='red', symbol='circle', line=dict(width=2, color='darkred')), - showlegend=False, - )) - - # Centerline - fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.5) - - fig.update_layout( - title="Complete Chamber Geometry: Chamber, Throat, Ablative, and Graphite", - xaxis_title="Axial Position [m]", - yaxis_title="Radius [m]", - height=600, - showlegend=True, - yaxis=dict(scaleanchor="x", scaleratio=1), # Equal aspect ratio - ) - - if save_path: - fig.write_image(save_path) - - return fig, b"" # DXF placeholder - - else: - # Matplotlib version (fallback) - fig, ax = plt.subplots(figsize=(14, 8)) - ax.set_aspect('equal') - - # Similar implementation with matplotlib - # (Keep existing matplotlib code as fallback) - - return fig, b"" - - -def select_optimal_geometry( - config: PintleEngineConfig, - design_requirements: Dict[str, Any], -) -> Dict[str, Any]: - """ - Select optimal geometry configuration from multiple sizing options. - - This function evaluates multiple geometry configurations and selects the best one - based on requirements (mass, performance, manufacturability, etc.). - - Parameters: - ----------- - config : PintleEngineConfig - Base engine configuration - design_requirements : dict - Design requirements including: - - target_thrust: Target thrust [N] - - burn_time: Burn time [s] - - max_mass: Maximum total mass [kg] - - min_performance: Minimum Isp [s] - - constraints: Additional constraints - - Returns: - -------- - optimal_config : dict - Optimal configuration selected - """ - # This is a placeholder - would implement full optimization here - # For now, return the input config with validation - - optimal = { - "config": config, - "meets_requirements": True, - "score": 1.0, - "reasoning": "Configuration meets all requirements", - } - - return optimal - diff --git a/EngineDesign/engine/pipeline/config_schemas.py b/EngineDesign/engine/pipeline/config_schemas.py index 5d69e8440..979125e31 100644 --- a/EngineDesign/engine/pipeline/config_schemas.py +++ b/EngineDesign/engine/pipeline/config_schemas.py @@ -157,6 +157,15 @@ class FeedSystemConfig(BaseModel): default="none", description="Pressure function type" ) + length: Optional[float] = Field( + default=None, + gt=0, + description=( + "Feed-line length from tank outlet to injector manifold [m]. Sets the line inertance " + "(length / area) in the chug model. Leave unset and the stability model records a " + "0.305 m assumption instead of using it silently." + ), + ) @model_validator(mode="before") @classmethod @@ -246,7 +255,6 @@ class RegenCoolingConfig(BaseModel): n_segments: int = Field(default=20, gt=0, description="Number of axial segments for heat-transfer integration") gas_turbulence_intensity: float = Field(default=0.1, ge=0, description="Estimated turbulence intensity of hot gas (0-1)") coolant_turbulence_intensity: float = Field(default=0.05, ge=0, description="Estimated turbulence intensity of coolant (0-1)") - hot_gas_cp: float = Field(default=2200.0, gt=0, description="Hot-gas specific heat [J/(kg·K)]") recovery_factor: Optional[float] = Field(default=None, gt=0, le=1, description="Turbulent boundary layer recovery factor for adiabatic wall temperature (Taw = Tc × recovery_factor). Typical range: 0.90-0.98. If None, uses default from constants.") @@ -359,8 +367,6 @@ class StainlessSteelCaseConfig(BaseModel): specific_heat: float = Field(default=500.0, gt=0, description="Specific heat [J/(kg·K)]") max_temperature: float = Field(default=1000.0, gt=0, description="Maximum allowable temperature [K] (melting point ~1700K, but limit lower for structural integrity)") emissivity: float = Field(default=0.3, ge=0, le=1, description="Surface emissivity") - yield_strength: float = Field(default=200e6, gt=0, description="Yield strength at max temp [Pa]") - youngs_modulus: float = Field(default=200e9, gt=0, description="Young's modulus [Pa]") class AblativeCoolingConfig(BaseModel): @@ -641,10 +647,6 @@ class CombustionEfficiencyConfig(BaseModel): # --- Mixing efficiency (Rupe momentum-ratio model) --- # Replaces the old k-e near-field mixing model + the eta_turbulence step-function. # eta_mix = Em_peak * exp(-(ln(R/R_opt))^2 / (2*sigma^2)), R = injector momentum ratio. - mixing_model: Literal["rupe"] = Field( - default="rupe", - description="Mixing efficiency model. 'rupe': momentum-ratio mixing efficiency (Rupe/SP-8089)." - ) Em_peak: float = Field( default=0.96, ge=0.5, le=1.0, description="Peak (best-achievable) mixing efficiency at the balanced momentum ratio. " @@ -806,6 +808,57 @@ class CombustionConfig(BaseModel): efficiency: CombustionEfficiencyConfig = Field(default_factory=CombustionEfficiencyConfig) +class StabilityConfig(BaseModel): + """Inputs to the combustion / feed stability model that belong to neither the propellant + (``fluids``) nor the plumbing (``feed_system``): the combustion-response calibration, the + nozzle-entrance Mach the acoustic damping uses, the acoustic damping coefficients, and the + dome-regulator dynamics. A ``None`` here means "derive it" and the derivation is recorded in + the assumptions registry (rich report -> assumptions.fallbacks_used), never substituted silently. + """ + n_interaction: float = Field( + default=0.5, gt=0, + description="Crocco interaction index n (calibration range 0.3-0.6). The forward-mode slider overrides it per run.", + ) + chi_acoustic: float = Field( + default=0.15, gt=0, le=1, + description="Sensitive-lag fraction chi: tau_sens = chi * tau_vap for the acoustic n-tau driving.", + ) + mach_nozzle_entrance: Optional[float] = Field( + default=None, gt=0, lt=1, + description="Mean Mach at the nozzle entrance (sets nozzle damping). None = solve it from the contraction ratio (isentropic, subsonic).", + ) + damping_injector_frac: float = Field( + default=0.02, ge=0, + description="Injector-face acoustic damping as a fraction of pi*f [-]. First-cut; calibrate against a cold ring-down test.", + ) + damping_twophase_frac: float = Field( + default=0.03, ge=0, + description="Two-phase (droplet) acoustic damping as a fraction of pi*f*droplet_loading [-]. First-cut.", + ) + droplet_loading: float = Field( + default=1.0, ge=0, + description="Relative liquid loading near the injector face for the two-phase damping term [-].", + ) + acoustic_gate_alpha_offset: float = Field( + default=350.0, ge=0, + description=( + "Calibration allowance for the acoustic gate [1/s]: a mode growing slower than this still " + "maps to a neutral gate margin because the a-priori damping coefficients are un-measured. " + "Set 0 for the strict alpha < 0 criterion." + ), + ) + regulator_enabled: bool = Field(default=True, description="Model the dome regulator upstream of each tank in the chug loop.") + regulator_corner_hz: float = Field(default=3.0, gt=0, description="Regulator response corner frequency [Hz].") + regulator_Z_hf: float = Field( + default=0.0, ge=0, + description="Regulator high-frequency series impedance [Pa*s/kg]. 0 = ideal pressure source (optimistic); measure via a step test.", + ) + regulator_max_excursion_psi: float = Field( + default=0.0, ge=0, + description="Regulator outlet pressure excursion bound [psi]. Reporting only; not a pole-shifter.", + ) + + class ChamberGeometryConfig(BaseModel): """ Unified chamber geometry configuration for solve_chamber_geometry_with_cea. @@ -908,13 +961,13 @@ class LOXTankConfig(BaseModel): class FuelTankConfig(BaseModel): - """Fuel tank geometry configuration for flight simulation""" - rp1_h: float = Field(gt=0, description="RP-1 tank height (internal cylindrical length, not including end caps) [m]") - rp1_radius: float = Field(gt=0, description="RP-1 tank internal radius [m]") + """Fuel tank geometry configuration for flight simulation. The rp1_* field names are legacy; the tank holds whichever fuel the config names.""" + rp1_h: float = Field(gt=0, description="Fuel tank height (internal cylindrical length, not including end caps) [m]") + rp1_radius: float = Field(gt=0, description="Fuel tank internal radius [m]") fuel_tank_pos: float = Field(description="Fuel tank center position relative to nozzle exit (positive = above, negative = below nozzle) [m]") - mass: Optional[float] = Field(default=None, gt=0, description="Initial RP-1 PROPELLANT mass [kg] (liquid only, not tank structure). Depletes during burn.") + mass: Optional[float] = Field(default=None, gt=0, description="Initial fuel PROPELLANT mass [kg] (liquid only, not tank structure). Depletes during burn.") initial_pressure_psi: Optional[float] = Field(default=None, gt=0, description="Initial fuel tank pressure [psi]") - tank_volume_m3: Optional[float] = Field(default=None, gt=0, description="RP-1 tank volume [m³]. If not provided, will be calculated from rp1_h and rp1_radius using π×r²×h") + tank_volume_m3: Optional[float] = Field(default=None, gt=0, description="Fuel tank volume [m³]. If not provided, will be calculated from rp1_h and rp1_radius using π×r²×h (field names are legacy)") class PressTankConfig(BaseModel): @@ -1915,6 +1968,7 @@ class PintleEngineConfig(BaseModel): chamber: Optional[ChamberConfig] = Field(default=None, description="Legacy chamber config (use chamber_geometry instead)") nozzle: Optional[NozzleConfig] = Field(default=None, description="Legacy nozzle config (use chamber_geometry instead)") solver: SolverConfig = Field(default_factory=SolverConfig) + stability: StabilityConfig = Field(default_factory=StabilityConfig, description="Combustion / feed stability model inputs (calibration, regulator, acoustic damping)") optimizer: Optional[OptimizerConfig] = Field(default=None, description="Optimizer configuration") # Flight simulation fields (optional) lox_tank: Optional[LOXTankConfig] = Field(default=None, description="LOX tank configuration for flight simulation") @@ -1992,11 +2046,7 @@ def sync_burn_time_fields(self): sync_burn_time_fields(self) return self - class Config: - # NOTE: "allow" ACCEPTS unknown YAML keys (stores them as extra attributes) - # — it does not reject them. Kept permissive for legacy configs; typo'd - # keys are therefore silently inert. - extra = "allow" + model_config = ConfigDict(extra="allow") def ensure_chamber_geometry(config: PintleEngineConfig) -> ChamberGeometryConfig: diff --git a/EngineDesign/engine/pipeline/physics_based_replacements.py b/EngineDesign/engine/pipeline/physics_based_replacements.py index 4ae5d8fcb..d35c6d7ed 100644 --- a/EngineDesign/engine/pipeline/physics_based_replacements.py +++ b/EngineDesign/engine/pipeline/physics_based_replacements.py @@ -239,62 +239,6 @@ def calculate_throat_heat_flux_physics( return float(heat_flux_throat) -def calculate_recirculation_intensity_physics( - fuel_velocity: float, - lox_velocity: float, - d_pintle_tip: float, - D_chamber: float, - Re_injector: float, -) -> float: - """ - Calculate recirculation intensity based on physics. - - Physics: - - Recirculation intensity depends on velocity ratio - - Higher velocity difference → stronger recirculation - - Depends on Reynolds number (turbulent flow) - - Scales with injector size - - Parameters: - ----------- - fuel_velocity : float - Fuel injection velocity [m/s] - lox_velocity : float - LOX injection velocity [m/s] - d_pintle_tip : float - Pintle tip diameter [m] - D_chamber : float - Chamber diameter [m] - Re_injector : float - Injector Reynolds number - - Returns: - -------- - intensity : float - Recirculation intensity (0-1) - """ - # Velocity difference drives recirculation - velocity_diff = abs(fuel_velocity - lox_velocity) - velocity_avg = (fuel_velocity + lox_velocity) / 2.0 - velocity_ratio = velocity_diff / (velocity_avg + 1e-10) - - # Base intensity from velocity ratio - # Higher velocity difference → stronger recirculation - base_intensity = 0.2 * velocity_ratio # Physics-based scaling - - # Reynolds number effect: higher Re → more turbulent → stronger recirculation - Re_factor = np.clip(Re_injector / 1e4, 0.5, 2.0) - Re_enhancement = 1.0 + 0.3 * np.log10(max(Re_factor, 0.1)) - - # Pintle size effect: larger pintle → larger recirculation - pintle_ratio = d_pintle_tip / (D_chamber + 1e-10) - pintle_factor = 1.0 + 0.2 * np.clip(pintle_ratio - 0.1, 0.0, 0.3) - - intensity = base_intensity * Re_enhancement * pintle_factor - - return float(np.clip(intensity, 0.0, 0.8)) - - def calculate_turbulence_enhancement_physics( Re_throat: float, velocity_ratio: float, diff --git a/EngineDesign/engine/pipeline/reaction_chemistry.py b/EngineDesign/engine/pipeline/reaction_chemistry.py index b971de173..fd2837b13 100644 --- a/EngineDesign/engine/pipeline/reaction_chemistry.py +++ b/EngineDesign/engine/pipeline/reaction_chemistry.py @@ -24,14 +24,8 @@ def _fuel_name_for_kinetics(config: PintleEngineConfig) -> str: """Label used for Arrhenius fuel-type branching (matches reaction_rate_constant lists). - Priority: legacy ``propellants.fuel.name`` → ``combustion.cea.fuel_name`` → ``fluids['fuel'].name`` - → ``\"RP-1\"``. + Priority: ``combustion.cea.fuel_name`` -> ``fluids['fuel'].name`` -> generic hydrocarbon ("RP-1"). """ - prop = getattr(config, "propellants", None) - if prop is not None and getattr(prop, "fuel", None) is not None: - name = getattr(prop.fuel, "name", None) - if name: - return str(name) cea = getattr(getattr(config, "combustion", None), "cea", None) if cea is not None: fn = getattr(cea, "fuel_name", None) @@ -48,21 +42,18 @@ def _fuel_name_for_kinetics(config: PintleEngineConfig) -> str: def _fuel_props_for_evaporation(config: PintleEngineConfig) -> Optional[Dict[str, float]]: """Density and boiling point for droplet evaporation time scale.""" - prop = getattr(config, "propellants", None) - if prop is not None and getattr(prop, "fuel", None) is not None: - bp = getattr(prop.fuel, "boiling_point", None) - return { - "density": float(prop.fuel.density), - "boiling_point": float(bp) if bp is not None else 489.0, - } fluids = getattr(config, "fluids", None) if not fluids or "fuel" not in fluids: return None fuel = fluids["fuel"] bp = getattr(fuel, "boiling_point", None) + if bp is None: + from engine.pipeline.assumptions import assume + bp = assume("kinetics.fuel.boiling_point", 489.0, unit="K", + reason=f"fluids.fuel.boiling_point missing for {getattr(fuel, 'name', '?')} (RP-1 value used; set it or load a propellant preset)") return { "density": float(fuel.density), - "boiling_point": float(bp) if bp is not None else 489.0, + "boiling_point": float(bp), } @@ -142,6 +133,20 @@ def calculate_reaction_progress( return float(progress) +_EA_MR_W = 0.10 # half-width [MR] of the smooth blend at the 1.5 / 3.0 boundaries + + +def _ea_mr_factor(MR: float) -> float: + """Activation-energy multiplier vs O/F: 1.2 (fuel-rich) -> 1.0 -> 0.9 (oxidizer-rich), C1-smooth.""" + def smooth(t): + t = min(max(t, 0.0), 1.0) + return t * t * (3.0 - 2.0 * t) + w = _EA_MR_W + lo = 1.2 + (1.0 - 1.2) * smooth((MR - (1.5 - w)) / (2.0 * w)) # 1.2 -> 1.0 around 1.5 + hi = 1.0 + (0.9 - 1.0) * smooth((MR - (3.0 - w)) / (2.0 * w)) # 1.0 -> 0.9 around 3.0 + return float(lo if MR < 2.25 else hi) + + def calculate_reaction_rate_constant( Pc: float, Tc: float, @@ -251,12 +256,12 @@ def calculate_reaction_rate_constant( Ea = 80000.0 n_pressure = 0.8 - # Adjust activation energy based on mixture ratio - # Fuel-rich or oxidizer-rich can have different effective Ea - if MR < 1.5: # Fuel-rich: more complex chemistry - Ea *= 1.2 # Higher effective activation energy - elif MR > 3.0: # Oxidizer-rich: simpler chemistry - Ea *= 0.9 # Lower effective activation energy + # Effective activation energy vs mixture ratio: fuel-rich chemistry is slower (x1.2), + # oxidizer-rich faster (x0.9). Blended with a C1 smoothstep across +/-0.10 MR of the 1.5 and + # 3.0 boundaries -- the previous hard `if` put a 20% step in Ea at exactly MR = 1.5, which + # is inside the ethalox design band, and a step in the objective breaks any gradient-based + # or secant refinement that crosses it (same defect as combustion_physics._ea_norm_from_mr). + Ea *= _ea_mr_factor(MR) # Pre-exponential with pressure dependence # A(P) = A0 × (P / P0)^n_pre, where P0 = 1 MPa reference diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py index 4fde0bc26..92a336594 100644 --- a/EngineDesign/engine/pipeline/stability/analysis.py +++ b/EngineDesign/engine/pipeline/stability/analysis.py @@ -19,7 +19,8 @@ import os from typing import Dict, Tuple, Optional, List, Any import numpy as np -from engine.pipeline.config_schemas import PintleEngineConfig +from engine.pipeline.config_schemas import PintleEngineConfig, StabilityConfig +from engine.pipeline.constants import DEFAULT_HOT_GAS_THERMAL_COND_W_M_K, DEFAULT_HOT_GAS_VISC_PA_S # --------------------------------------------------------------------------- @@ -36,124 +37,48 @@ def calculate_chugging_frequency( Tc: Optional[float] = None, ) -> Dict[str, float]: """ - Estimate low frequency combustion instability (chugging) characteristics. + Order-of-magnitude chug (bulk-mode) frequency estimates from chamber geometry alone. - We combine two simple notions: - - Residence time, tau_res = L* / c* - - Helmholtz-like volume-compliance mode if gas properties are known - - Parameters - ---------- - chamber_volume : float - Chamber volume [m^3] - throat_area : float - Throat area [m^2] - cstar : float - Characteristic velocity [m/s] - gamma : float - Specific heat ratio [-] - Pc : float - Chamber pressure [Pa] - R : float, optional - Gas constant [J/(kg K)]. If provided with Tc, used for Helmholtz estimate. - Tc : float, optional - Chamber temperature [K]. If provided with R, used for Helmholtz estimate. + Two estimates: the residence-time frequency ``1 / (2 pi tau_res)`` with ``tau_res = L*/c*``, and + a Helmholtz bulk mode using the throat as the neck. These are *placeholders* -- the physical chug + frequency comes from the feed-coupled loop in ``chug.py`` and overwrites ``frequency`` in + ``comprehensive_stability_analysis``. The old heuristic "stability_index" / "stability_margin" + that used to ride along here (floored at 0.4 and mapped to a margin so that "reasonable designs + achieve required margins") was not a physical quantity and has been removed; margins come from + the gain-margin model only. Returns ------- dict - - frequency: dominant chugging frequency [Hz] - - frequency_residence: frequency from 1 / (2 pi tau_res) [Hz] - - frequency_helmholtz: Helmholtz estimate if possible [Hz or np.nan] - - period: oscillation period [s] - - stability_index: heuristic index (higher is better) - - stability_margin: backward compatibility field (maps from stability_index) - - tau_residence: residence time L* / c* [s] + - frequency: Helmholtz estimate when gas properties are known, else the residence estimate [Hz] + - frequency_residence, frequency_helmholtz: the two estimates [Hz] (nan if unavailable) + - period: 1 / frequency [s] + - tau_residence: L* / c* [s] - Lstar: characteristic length [m] """ if throat_area <= 0.0 or chamber_volume <= 0.0 or cstar <= 0.0: - # Fallback values - Lstar = 1.0 - tau_residence = 1.0e-3 - else: - Lstar = chamber_volume / throat_area - tau_residence = Lstar / cstar - - # Frequency from residence time + return {"frequency": float("nan"), "frequency_residence": float("nan"), + "frequency_helmholtz": float("nan"), "period": float("nan"), + "tau_residence": float("nan"), "Lstar": float("nan")} + Lstar = chamber_volume / throat_area + tau_residence = Lstar / cstar freq_res = 1.0 / (2.0 * np.pi * tau_residence) - # Helmholtz-like frequency if we know gas properties - # f_H ≈ (c / (2 pi)) * sqrt(A_neck / (V * L_eff)) - # Use throat as neck and L_eff ~ D_throat - if R is not None and Tc is not None and throat_area > 0.0 and chamber_volume > 0.0: - # FIXED: Add safety checks for sqrt operations - a = float(np.sqrt(max(0, gamma * R * Tc))) - d_throat = np.sqrt(max(0, 4.0 * throat_area / np.pi)) + # Helmholtz-like bulk mode: f_H = (a / 2pi) * sqrt(A_neck / (V * L_eff)), neck = throat, + # L_eff ~ half a throat diameter. + freq_helm = float("nan") + if R is not None and Tc is not None and gamma * R * Tc > 0: + a = float(np.sqrt(gamma * R * Tc)) + d_throat = np.sqrt(4.0 * throat_area / np.pi) L_eff = max(0.5 * d_throat, 1.0e-3) - sqrt_arg = throat_area / (chamber_volume * L_eff) if chamber_volume * L_eff > 0 else 0.0 - freq_helm = (a / (2.0 * np.pi)) * np.sqrt(max(0, sqrt_arg)) - else: - freq_helm = np.nan - - # Choose dominant frequency for chugging - if np.isfinite(freq_helm): - freq = 0.5 * freq_res + 0.5 * freq_helm - else: - freq = freq_res - - # Clamp to an engineering range for reporting - freq = float(np.clip(freq, 1.0, 2000.0)) - freq_res = float(freq_res) - freq_helm = float(freq_helm) if np.isfinite(freq_helm) else float("nan") - - period = 1.0 / freq if freq > 0.0 else float("inf") - - # Simple stability index: - # - Better if Pc is higher - # - Better if L* is reasonably large (say ≥ 0.8 m) - # - Penalize if chugging frequency is very low (hard to damp) or in a problematic band - # FIXED: More lenient factors to allow reasonable designs to achieve stable margins - Pc_ref = 1.0e6 - Lstar_ref = 1.0 - # More lenient Pc factor - even 0.5 MPa can be acceptable - Pc_factor = min(1.0, (Pc / Pc_ref) ** 0.3) if Pc > 0 else 0.0 - # More lenient Lstar factor - even 0.6 m can be acceptable - Lstar_factor = min(1.0, (Lstar / Lstar_ref) ** 0.2) if Lstar > 0 else 0.0 - # Ensure minimum factors for reasonable designs - Pc_factor = max(Pc_factor, 0.6) if Pc > 0.3e6 else Pc_factor # At least 0.6 for Pc > 0.3 MPa - Lstar_factor = max(Lstar_factor, 0.7) if Lstar > 0.6 else Lstar_factor # At least 0.7 for L* > 0.6 m - - # Frequency health factor: prefer 20 to 400 Hz for chugging - # Much more lenient penalties to allow optimizer to find feasible solutions - if freq < 5.0: - f_factor = 0.6 # Very low frequencies - still penalized but not as harsh - elif freq < 10.0: - f_factor = 0.75 # Low frequencies - moderate penalty - elif freq > 600.0: - f_factor = 0.9 # High frequencies - minimal penalty - elif freq > 400.0: - f_factor = 0.95 # Moderate-high frequencies - very small penalty - else: - f_factor = 1.0 # Ideal range - - stability_index = Pc_factor * Lstar_factor * f_factor - # Ensure minimum index for reasonable designs - stability_index = max(stability_index, 0.4) # Minimum 0.4 for any reasonable design - - # Backward compatibility: map stability_index to stability_margin - # FIXED: More generous mapping to ensure reasonable designs can meet requirements - # For a reasonable design (index ~ 0.6-0.8), we want margin ~ 1.2-1.5 - # New mapping: margin = stability_index * 1.5 + 0.4 (gives 1.3 for index=0.6, 1.6 for index=0.8, 1.9 for index=1.0) - # This ensures reasonable designs can achieve required margins - stability_margin = stability_index * 1.5 + 0.4 # More generous mapping + freq_helm = float((a / (2.0 * np.pi)) * np.sqrt(throat_area / (chamber_volume * L_eff))) + freq = freq_helm if np.isfinite(freq_helm) else float(freq_res) return { "frequency": float(freq), - "frequency_residence": freq_res, - "frequency_helmholtz": freq_helm, - "period": float(period), - "stability_index": float(stability_index), - "stability_margin": float(stability_margin), # Backward compatibility + "frequency_residence": float(freq_res), + "frequency_helmholtz": float(freq_helm), + "period": float(1.0 / freq) if freq > 0 else float("inf"), "tau_residence": float(tau_residence), "Lstar": float(Lstar), } @@ -231,90 +156,54 @@ def analyze_feed_system_stability( pressure_drop: float, ) -> Dict[str, float]: """ - Analyze feed system stability (POGO, surge, water hammer). + Feed-line acoustics and the water-hammer bound for one propellant line. Parameters ---------- feed_line_length : float Feed line length [m] feed_line_diameter : float - Feed line diameter [m] + Feed line bore [m] propellant_density : float - Propellant density [kg/m^3] + Liquid density [kg/m^3] bulk_modulus : float - Bulk modulus [Pa] + Liquid bulk modulus [Pa] flow_velocity : float - Flow velocity [m/s] + Mean line velocity [m/s] pressure_drop : float - Pressure drop across feed system [Pa] + Tank-to-chamber pressure drop [Pa] Returns ------- dict - - pogo_frequency: quarter wave frequency [Hz] - - surge_frequency: half wave frequency [Hz] - - water_hammer_pressure: spike for full stop [Pa] + - pogo_frequency: quarter-wave line mode (closed-open) [Hz] + - surge_frequency: half-wave line mode (closed-closed) [Hz] + - water_hammer_pressure: Joukowsky spike for an instantaneous stop, rho*a*dv [Pa] - water_hammer_margin: pressure_drop / spike [-] - - stability_margin: backward compatibility field (maps from water_hammer_margin) - - sound_speed: wave speed in propellant [m/s] + - sound_speed: wave speed in the liquid [m/s] + + The feed-coupled *stability* margin is the chug gain margin from ``chug.py``; the caller writes + it into this dict as ``stability_margin``. The piecewise water-hammer-to-margin mapping that used + to live here (tuned so "typical optimized designs meet the 1.20 requirement", including a branch + that was literally a constant) was not a stability criterion and has been removed. """ L = max(feed_line_length, 1.0e-3) rho = propellant_density K = bulk_modulus sound_speed = float(np.sqrt(K / rho)) - pogo_frequency = float(sound_speed / (4.0 * L)) # closed-open surge_frequency = float(sound_speed / (2.0 * L)) # closed-closed delta_v = max(flow_velocity, 0.0) water_hammer_pressure = float(rho * sound_speed * delta_v) - - if water_hammer_pressure > 0.0: - water_hammer_margin = float(pressure_drop / water_hammer_pressure) - else: - water_hammer_margin = float("inf") - - # FIXED: Map water_hammer_margin to stability_margin accounting for real-world factors - # The theoretical water_hammer_pressure assumes instantaneous stop, which is overly conservative. - # In reality: - # - Valves close over time (0.1-1.0 s), reducing actual pressure spike by 50-90% - # - Systems have accumulators, surge suppressors, and compliance - # - Actual water hammer is typically 10-50% of theoretical maximum - # - # Map water_hammer_margin to stability_margin: - # - Display requirement is >= 1.20 (full min_stability_margin) - # - Optimizer convergence uses >= 0.96 (80% of 1.2) - # - Adjusted mapping to ensure typical designs meet the full 1.20 requirement - # - # Use a scaling function that makes reasonable designs achievable: - # For water_hammer_margin = 0.15-0.2 (typical), we want stability_margin >= 1.20 - if water_hammer_margin >= 0.5: - # Good margin: scale linearly from 0.5 -> 1.2 to higher values - stability_margin = 1.2 + (water_hammer_margin - 0.5) * 1.0 # 0.5 -> 1.2, 1.0 -> 1.7 - elif water_hammer_margin >= 0.05: - # Moderate margin: scale from 0.05 -> 1.20 to 0.5 -> 1.2 - # Typical optimized designs (0.05-0.2) should meet the 1.20 requirement - # This accounts for real-world valve closure times and system compliance - stability_margin = 1.20 + (water_hammer_margin - 0.05) / 0.45 * 0.0 # 0.05 -> 1.20, 0.5 -> 1.20 - elif water_hammer_margin >= 0.03: - # Very low margin: scale from 0.03 -> 1.15 to 0.05 -> 1.20 - # Still acceptable with proper engineering (valve closure, accumulators) - stability_margin = 1.15 + (water_hammer_margin - 0.03) / 0.02 * 0.05 # 0.03 -> 1.15, 0.05 -> 1.20 - else: - # Extremely low margin: scale from 0.0 -> 1.00 to 0.03 -> 1.15 - # Still give reasonable margin since real systems have mitigations - stability_margin = 1.00 + water_hammer_margin / 0.03 * 0.15 # 0.0 -> 1.00, 0.03 -> 1.15 - - # Clamp to reasonable range - stability_margin = float(np.clip(stability_margin, 0.1, 5.0)) + water_hammer_margin = float(pressure_drop / water_hammer_pressure) if water_hammer_pressure > 0.0 else float("inf") return { "pogo_frequency": pogo_frequency, "surge_frequency": surge_frequency, "water_hammer_pressure": water_hammer_pressure, "water_hammer_margin": water_hammer_margin, - "stability_margin": stability_margin, # Backward compatibility "sound_speed": sound_speed, } @@ -322,18 +211,18 @@ def analyze_feed_system_stability( # --------------------------------------------------------------------------- # Physical stability margins (new model) — fast tiers for the per-eval path # --------------------------------------------------------------------------- -# Interim gate-margin mappings: PHYSICAL and monotone in the growth rate, but conservatively centered so -# currently-healthy designs pass (directive: keep the gate's impact ~as-is) while clearly-unstable designs -# fail. The absolute calibration of the chug feed/regulator params and the acoustic damping coefficients is -# un-measured; these constants are re-tuned once tests T5/T6/T7/H3 land. Documented in the rebuild plan §5. -_CHUG_GATE_CENTER = 0.80 # chug gain margin -> "neutral" (gate margin 1.0) +# Gate-margin mappings, monotone in the physical quantity and centred on the physical criterion: +# * chug: Nyquist gain margin GM. GM = 1 is the stability boundary, so it maps to a neutral gate +# margin of 1.0 (the old centre of 0.80 called a GM of 0.85 -- an unstable loop -- "stable"). +# * acoustic: net growth rate alpha of the worst mode. alpha = 0 is the boundary, but the a-priori +# damping coefficients are un-measured, so a configurable allowance +# (stability.acoustic_gate_alpha_offset, default 350 1/s) keeps the gate's calibration explicit +# rather than hidden. Set it to 0 for the strict criterion. +_CHUG_GATE_CENTER = 1.00 # chug gain margin at the stability boundary -> gate margin 1.0 _CHUG_GATE_SCALE = 0.20 _GATE_SPAN = 0.30 # gate margin ranges ~[0.7, 1.3] -_ACOUSTIC_GATE_OFFSET = 350.0 # [1/s] alpha offset so marginal acoustic still passes +_ACOUSTIC_GATE_OFFSET = 350.0 # [1/s] default allowance; overridden by StabilityConfig _ACOUSTIC_GATE_SCALE = 1000.0 # [1/s] -# LOX property fallbacks (default.yaml leaves LOX latent_heat / boiling_point null). -_LOX_HFG_DEFAULT = 213000.0 # J/kg -_LOX_TBOIL_DEFAULT = 90.2 # K def _chug_gate_margin(gain_margin: float) -> float: @@ -342,10 +231,10 @@ def _chug_gate_margin(gain_margin: float) -> float: return float(1.0 + _GATE_SPAN * np.tanh((gain_margin - _CHUG_GATE_CENTER) / _CHUG_GATE_SCALE)) -def _acoustic_gate_margin(alpha_max: float) -> float: +def _acoustic_gate_margin(alpha_max: float, alpha_offset: float = _ACOUSTIC_GATE_OFFSET) -> float: if not np.isfinite(alpha_max): return 1.10 - return float(1.0 + _GATE_SPAN * np.tanh((_ACOUSTIC_GATE_OFFSET - alpha_max) / _ACOUSTIC_GATE_SCALE)) + return float(1.0 + _GATE_SPAN * np.tanh((float(alpha_offset) - alpha_max) / _ACOUSTIC_GATE_SCALE)) def _fluid_attr(fluids, key, attr, default): @@ -372,9 +261,91 @@ def _feed_attr(config, key, attr, default): return float(default) -# Default combustion-response parameters (calibration targets, see [Phys §2, §5.3]). -_N_INTERACTION_DEFAULT = 0.5 # interaction index n (sweep 0.3-0.6) -_CHI_ACOUSTIC_DEFAULT = 0.15 # sensitive-fraction chi for acoustic (tau_sens = chi*tau_vap) +# Handbook thermodynamic fallbacks BY FLUID, used only when the config omits a property. Every use +# is recorded in the assumptions registry. Previously the fuel fallbacks were methane's (h_fg 510 kJ/kg, +# T_boil 111.6 K) regardless of which fuel the config named, and the oxidizer's were LOX's. +# density kg/m^3 latent heat J/kg boiling point K (1 atm) +_FLUID_THERMO_FALLBACKS = { + "lox": (1140.0, 213000.0, 90.2), + "methane": ( 422.6, 510000.0, 111.65), + "ethanol": ( 789.0, 838000.0, 351.4), + "rp1": ( 810.0, 246000.0, 489.0), + "ipa": ( 786.0, 665000.0, 355.6), + "nitrousoxide": (1220.0, 376000.0, 184.7), +} +_THERMO_INDEX = {"density": (0, "kg/m^3"), "latent_heat": (1, "J/kg"), "boiling_point": (2, "K")} +_GENERIC_THERMO = {"fuel": (800.0, 300000.0, 450.0), "oxidizer": (1140.0, 213000.0, 90.2)} + + +def _fluid_thermo(config, key: str, attr: str) -> float: + """``fluids[key].attr`` from the config; else the handbook value for that named fluid; else a + generic value. Both fallbacks are recorded, and the generic one says the fluid was unrecognised.""" + v = _fluid_attr(getattr(config, "fluids", None), key, attr, None) + if v is not None: + return v + from engine.pipeline.assumptions import assume + from engine.pipeline.io import _canon_fluid + idx, unit = _THERMO_INDEX[attr] + try: + f = config.fluids[key] if isinstance(config.fluids, dict) else getattr(config.fluids, key) + name = getattr(f, "name", "") or "" + except Exception: + name = "" + canon = _canon_fluid(name) + if canon in _FLUID_THERMO_FALLBACKS: + return assume(f"stability.fluids.{key}.{attr}", _FLUID_THERMO_FALLBACKS[canon][idx], unit=unit, + reason=f"fluids.{key}.{attr} missing from config; handbook value for {name}") + return assume(f"stability.fluids.{key}.{attr}", _GENERIC_THERMO["oxidizer" if key == "oxidizer" else "fuel"][idx], + unit=unit, reason=f"fluids.{key}.{attr} missing and fluid {name!r} is not in the handbook table -- set it in the config") + + +def _feed_geometry(config, side: str) -> Tuple[float, float]: + """(length [m], flow area [m^2]) of one feed line for the chug inertance L/A. + + Length comes from ``feed_system..length`` -- a field that did not exist until now, which is + why the model used to carry a hardcoded 0.305 m for every engine. Area is the schema-derived + ``A_hydraulic`` (pi d^2/4 unless the user gave a non-circular passage). + """ + from engine.pipeline.assumptions import assume + L = _feed_attr(config, side, "length", float("nan")) + if not np.isfinite(L) or L <= 0.0: + L = assume(f"stability.feed.{side}.length", 0.305, unit="m", + reason=f"feed_system.{side}.length not set (tank-outlet to manifold run)") + A = _feed_attr(config, side, "A_hydraulic", float("nan")) + if not np.isfinite(A) or A <= 0.0: + d = _feed_attr(config, side, "d_inlet", float("nan")) + if np.isfinite(d) and d > 0.0: + A = float(np.pi * (d / 2.0) ** 2) + else: + A = float(np.pi * (assume(f"stability.feed.{side}.d_inlet", 0.0127, unit="m", + reason=f"feed_system.{side} has no bore") / 2.0) ** 2) + return float(L), float(A) + + +def _chamber_dims(config, cg) -> Tuple[float, float]: + """(L_chamber, D_chamber) [m]. The solved unified geometry first: its ``length`` is the total + chamber length and its ``chamber_diameter`` is the cylindrical bore, which is what the transverse + acoustic modes live in. Legacy configs fall back to ``chamber.length`` and the volume-mean + diameter; anything still missing is a recorded assumption.""" + from engine.pipeline.assumptions import assume + L = float(getattr(cg, "length", None) or 0.0) + if L <= 0.0: + L = float(getattr(getattr(config, "chamber", None), "length", None) or 0.0) + if L <= 0.0: + L = assume("stability.chamber.length", 0.18, unit="m", reason="no solved or configured chamber length") + D = float(getattr(cg, "chamber_diameter", None) or 0.0) + if D <= 0.0: + V = float(getattr(cg, "volume", None) or 0.0) + if V > 0.0 and L > 0.0: + D = float(np.sqrt(4.0 * V / (np.pi * L))) + else: + D = assume("stability.chamber.diameter", 0.1, unit="m", reason="no chamber diameter or volume") + return L, D + + +def _stability_config(config) -> StabilityConfig: + sc = getattr(config, "stability", None) + return sc if isinstance(sc, StabilityConfig) else StabilityConfig() def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, cstar: float, @@ -383,19 +354,36 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta """Extract chug/acoustic model inputs from config + diagnostics. Shared by the fast path (compute_physical_stability) and the rich report (report.py) so they use IDENTICAL extraction. [Phys §3.2, §4, §5] + + Sources, in order: solved geometry (``cg``), the closure diagnostics of this evaluation, the + config (``fluids`` for the propellants, ``feed_system`` for the plumbing, ``stability`` for the + model calibration), and finally recorded assumptions -- never a silent constant. """ from engine.pipeline.stability import core, chug, acoustic + from engine.pipeline.assumptions import assume + sc = _stability_config(config) A_t = float(cg.A_throat) V_c = float(cg.volume) Lstar = V_c / A_t if A_t > 0 else float(getattr(cg, "Lstar", 0.8)) - L_ch = float(getattr(cg, "length", 0.0)) or float(getattr(config.chamber, "length", 0.18) or 0.18) - D_ch = float(np.sqrt(max(0.0, 4.0 * V_c / (np.pi * L_ch)))) if (V_c > 0 and L_ch > 0) else 0.1 + L_ch, D_ch = _chamber_dims(config, cg) + A_c = float(np.pi * (D_ch / 2.0) ** 2) + contraction_ratio = A_c / A_t if A_t > 0 else float("nan") + # Hot-gas transport properties: the same ones the thermal model uses (regen_cooling block is the + # engine's hot-gas property record, regardless of whether regen is enabled). rc = getattr(config, "regen_cooling", None) - k_g = float(getattr(rc, "hot_gas_thermal_conductivity", 0.12) or 0.12) - cp_g = float(getattr(rc, "hot_gas_cp", 0.0) or 0.0) or (gamma * R / (gamma - 1.0)) - mu_g = float(getattr(rc, "hot_gas_viscosity", 4.0e-5) or 4.0e-5) + k_g = float(getattr(rc, "hot_gas_thermal_conductivity", 0.0) or 0.0) if rc is not None else 0.0 + if k_g <= 0.0: + k_g = assume("stability.hot_gas_thermal_conductivity", DEFAULT_HOT_GAS_THERMAL_COND_W_M_K, + unit="W/(m*K)", reason="regen_cooling.hot_gas_thermal_conductivity not set") + mu_g = float(getattr(rc, "hot_gas_viscosity", 0.0) or 0.0) if rc is not None else 0.0 + if mu_g <= 0.0: + mu_g = assume("stability.hot_gas_viscosity", DEFAULT_HOT_GAS_VISC_PA_S, + unit="Pa*s", reason="regen_cooling.hot_gas_viscosity not set") + # Product-gas cp from the CEA state of THIS evaluation (gamma, R). This used to read a fixed + # regen_cooling.hot_gas_cp = 2200 J/(kg*K) for every propellant. + cp_g = gamma * R / (gamma - 1.0) rho_g = Pc / (R * Tc) if (R > 0 and Tc > 0) else 2.0 nu_g = mu_g / rho_g if rho_g > 0 else 2.0e-5 a_snd = core.sound_speed(gamma, R, Tc) @@ -418,56 +406,70 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta eta_O = dpiO / Pc if Pc > 0 else 0.3 eta_F = dpiF / Pc if Pc > 0 else 0.3 - from engine.pipeline.assumptions import assume - - def _fluid(key, attr, fb_value, fb_unit): - v = _fluid_attr(config.fluids, key, attr, None) - if v is not None: - return v - return assume(f"stability.fluids.{key}.{attr}", fb_value, unit=fb_unit, - reason=f"fluids.{key}.{attr} missing from config (use a propellant preset)") - - rho_O = _fluid("oxidizer", "density", 1140.0, "kg/m^3") - rho_F = _fluid("fuel", "density", 422.6, "kg/m^3") # methalox-lineage fallback — recorded - hfg_O = _fluid("oxidizer", "latent_heat", _LOX_HFG_DEFAULT, "J/kg") - hfg_F = _fluid("fuel", "latent_heat", 510000.0, "J/kg") - tbO = _fluid("oxidizer", "boiling_point", _LOX_TBOIL_DEFAULT, "K") - tbF = _fluid("fuel", "boiling_point", 111.6, "K") - K_bulk_O = _fluid("oxidizer", "bulk_modulus_pa", 1.5e9, "Pa") + rho_O = _fluid_thermo(config, "oxidizer", "density") + rho_F = _fluid_thermo(config, "fuel", "density") + hfg_O = _fluid_thermo(config, "oxidizer", "latent_heat") + hfg_F = _fluid_thermo(config, "fuel", "latent_heat") + tbO = _fluid_thermo(config, "oxidizer", "boiling_point") + tbF = _fluid_thermo(config, "fuel", "boiling_point") + K_bulk_O = _fluid_attr(config.fluids, "oxidizer", "bulk_modulus_pa", None) + if K_bulk_O is None: + K_bulk_O = assume("stability.fluids.oxidizer.bulk_modulus_pa", 1.5e9, unit="Pa", + reason="fluids.oxidizer.bulk_modulus_pa missing (set via propellant preset); measure via water-hammer test T5") tau_conv_O, _, K_v_O = core.lags_from_smd(D32_O, k_g=k_g, rho_l=rho_O, cp_g=cp_g, T_inf=Tc, T_boil=tbO, h_fg=hfg_O, chi=1.0) tau_conv_F, _, K_v_F = core.lags_from_smd(D32_F, k_g=k_g, rho_l=rho_F, cp_g=cp_g, T_inf=Tc, T_boil=tbF, h_fg=hfg_F, chi=1.0) if not np.isfinite(tau_conv_O): - tau_conv_O = 2.0e-3 + tau_conv_O = assume("stability.tau_conv_O", 2.0e-3, unit="s", + reason="d^2-law oxidizer lag non-finite (check T_boil < Tc and h_fg)") if not np.isfinite(tau_conv_F): - tau_conv_F = 1.5e-3 + tau_conv_F = assume("stability.tau_conv_F", 1.5e-3, unit="s", + reason="d^2-law fuel lag non-finite (check T_boil < Tc and h_fg)") - feed_len = 0.305 - dO = _feed_attr(config, "oxidizer", "d_inlet", 0.0135) - dF = _feed_attr(config, "fuel", "d_inlet", 0.0095) - reg_O = chug.Regulator(enabled=True) - reg_F = chug.Regulator(enabled=True) + L_feed_O, A_feed_O = _feed_geometry(config, "oxidizer") + L_feed_F, A_feed_F = _feed_geometry(config, "fuel") + reg_kw = dict(enabled=bool(sc.regulator_enabled), corner_hz=float(sc.regulator_corner_hz), + Z_hf=float(sc.regulator_Z_hf), max_excursion_pa=float(sc.regulator_max_excursion_psi) * 6894.757) streams = [ chug.ChugStream("O", mdot=mdot_O, eta_inj=max(eta_O, 1e-3), Pc=Pc, dP_feed=dpfO, - feed_length=feed_len, feed_area=np.pi * (dO / 2.0) ** 2, tau_conv=tau_conv_O, regulator=reg_O), + feed_length=L_feed_O, feed_area=A_feed_O, tau_conv=tau_conv_O, + regulator=chug.Regulator(**reg_kw)), chug.ChugStream("F", mdot=mdot_F, eta_inj=max(eta_F, 1e-3), Pc=Pc, dP_feed=dpfF, - feed_length=feed_len, feed_area=np.pi * (dF / 2.0) ** 2, tau_conv=tau_conv_F, regulator=reg_F), + feed_length=L_feed_F, feed_area=A_feed_F, tau_conv=tau_conv_F, + regulator=chug.Regulator(**reg_kw)), ] chamber = chug.ChugChamber(cstar=cstar, A_t=A_t, Lstar=Lstar, gamma=gamma) - chi_ac = float(ov.get("chi_acoustic", _CHI_ACOUSTIC_DEFAULT)) - n_int = float(ov.get("n_interaction", _N_INTERACTION_DEFAULT)) + chi_ac = float(ov.get("chi_acoustic", sc.chi_acoustic)) + n_int = float(ov.get("n_interaction", sc.n_interaction)) tau_sens = chi_ac * tau_conv_O # LOX-side rate-limiting; sensitive lag << transport lag [Phys §5] - gas = acoustic.GasState(gamma=gamma, a_sound=a_snd, nu_g=nu_g, mach_nozzle_entrance=0.2) + + # Nozzle-entrance Mach sets the convective (nozzle) damping. Config value if given, else the + # subsonic isentropic solution for the actual contraction ratio (a fixed 0.2 corresponds to a + # contraction ratio of ~2.9 and overstated nozzle damping for every wider chamber). + M_ne = sc.mach_nozzle_entrance + if M_ne is None: + M_ne = core.mach_from_area_ratio_subsonic(contraction_ratio, gamma) if np.isfinite(contraction_ratio) else float("nan") + if not np.isfinite(M_ne) or M_ne <= 0.0: + M_ne = assume("stability.mach_nozzle_entrance", 0.2, unit="-", + reason="contraction ratio unavailable for the isentropic solve") + gas = acoustic.GasState(gamma=gamma, a_sound=a_snd, nu_g=nu_g, mach_nozzle_entrance=float(M_ne)) + coeffs = acoustic.DampingCoeffs(injector_frac=float(sc.damping_injector_frac), + twophase_frac=float(sc.damping_twophase_frac), + droplet_loading=float(sc.droplet_loading)) return { - "streams": streams, "chamber": chamber, "gas": gas, - "D_ch": D_ch, "L_ch": L_ch, "Lstar": Lstar, + "streams": streams, "chamber": chamber, "gas": gas, "damping_coeffs": coeffs, + "acoustic_gate_alpha_offset": float(sc.acoustic_gate_alpha_offset), + "D_ch": D_ch, "L_ch": L_ch, "Lstar": Lstar, "contraction_ratio": contraction_ratio, + "mach_nozzle_entrance": float(M_ne), "tau_conv_O": tau_conv_O, "tau_conv_F": tau_conv_F, "tau_sens": tau_sens, "chi_acoustic": chi_ac, "n_interaction": n_int, "eta_inj_O": eta_O, "eta_inj_F": eta_F, "D32_O": D32_O, "D32_F": D32_F, "K_v_O": K_v_O, "K_v_F": K_v_F, "rho_O": rho_O, "rho_F": rho_F, "K_bulk_O": K_bulk_O, + "feed_length_O": L_feed_O, "feed_length_F": L_feed_F, + "u_O": diagnostics.get("u_O"), "Cd_O": diagnostics.get("Cd_O"), "Pc": Pc, "wh_pressure_pa": None, } @@ -500,16 +502,19 @@ def compute_physical_stability(config, Pc: float, MR: float, mdot_total: float, # difference does not earn a kernel. (The chug sweep did: 200 complex points, # measured at ~8.8% of Layer-1 wall time when left unaccelerated.) ac_fast = acoustic.fast_acoustic(inp["D_ch"], inp["L_ch"], inp["gas"], - n=inp["n_interaction"], tau_sens=inp["tau_sens"]) + n=inp["n_interaction"], tau_sens=inp["tau_sens"], + coeffs=inp["damping_coeffs"]) return { "chug": chug_fast, "acoustic": ac_fast, "chug_gate_margin": _chug_gate_margin(chug_fast.get("gain_margin", float("nan"))), - "acoustic_gate_margin": _acoustic_gate_margin(ac_fast.get("alpha_max", float("nan"))), + "acoustic_gate_margin": _acoustic_gate_margin(ac_fast.get("alpha_max", float("nan")), + inp["acoustic_gate_alpha_offset"]), "f_chug_hz": chug_fast.get("f_chug_hz"), "tau_conv_O": inp["tau_conv_O"], "tau_conv_F": inp["tau_conv_F"], "tau_sens": inp["tau_sens"], "eta_inj_O": inp["eta_inj_O"], "eta_inj_F": inp["eta_inj_F"], "D_ch": inp["D_ch"], "L_ch": inp["L_ch"], + "mach_nozzle_entrance": inp["mach_nozzle_entrance"], } @@ -551,15 +556,7 @@ def comprehensive_stability_analysis( V_chamber = float(cg.volume) A_throat = float(cg.A_throat) Lstar = V_chamber / A_throat if A_throat > 0.0 else cg.Lstar - - # Estimate chamber dimensions - L_chamber = getattr(config.chamber, "length", 0.18) or 0.18 - L_chamber = float(L_chamber) - if L_chamber <= 0.0: - L_chamber = 0.18 - - # FIXED: Add safety check for sqrt operation - D_chamber = float(np.sqrt(max(0, 4.0 * V_chamber / (np.pi * L_chamber)))) if V_chamber > 0.0 and L_chamber > 0 else 0.1 + L_chamber, D_chamber = _chamber_dims(config, cg) # Combustion stability chugging = calculate_chugging_frequency( @@ -580,27 +577,11 @@ def comprehensive_stability_analysis( R=R, ) - # Feed system stability (use LOX feed as representative) - if getattr(config, "feed_system", None) is not None: - if isinstance(config.feed_system, dict): - lox_config = config.feed_system.get("lox", {}) - if isinstance(lox_config, dict): - feed_length = float(lox_config.get("length", 1.0)) - feed_diameter = float(lox_config.get("d_inlet", 0.01)) - else: - feed_length = float(getattr(lox_config, "length", 1.0)) - feed_diameter = float(getattr(lox_config, "d_inlet", 0.01)) - else: - lox_config = getattr(config.feed_system, "lox", None) - if lox_config is not None: - feed_length = float(getattr(lox_config, "length", 1.0)) - feed_diameter = float(getattr(lox_config, "d_inlet", 0.01)) - else: - feed_length = 1.0 - feed_diameter = 0.01 - else: - feed_length = 1.0 - feed_diameter = 0.01 + # Feed-line acoustics on the oxidizer line (the stiffer, denser side; representative). Length + # and bore come from feed_system.oxidizer -- the old lookup asked for a "lox" branch and a + # "length" attribute that never existed, so it always fell through to 1.0 m x 10 mm. + feed_length, A_feed = _feed_geometry(config, "oxidizer") + feed_diameter = float(np.sqrt(4.0 * A_feed / np.pi)) # Oxidizer density / bulk modulus from config.fluids (the old `config.propellants` lookup was a # dead key — it ALWAYS fell through to 1140. UNIFICATION P2c: config-first, recorded fallback.) @@ -615,8 +596,7 @@ def comprehensive_stability_analysis( bulk_modulus = assume("stability.feed.bulk_modulus_O", 1.5e9, unit="Pa", reason="fluids.oxidizer.bulk_modulus_pa missing (set via propellant preset); measure via water-hammer test T5") - # Estimate oxidizer flow velocity - A_feed = np.pi * (feed_diameter / 2.0) ** 2 + # Mean oxidizer line velocity mdot_ox = float(diagnostics.get("mdot_O", mdot_total * MR / (1.0 + MR))) flow_velocity = float(mdot_ox / (prop_density * A_feed)) if A_feed > 0.0 else 0.0 @@ -640,6 +620,40 @@ def comprehensive_stability_analysis( for i, freq in enumerate(acoustic_raw["transverse_modes"]): acoustic_modes_dict[f"T{i+1}"] = freq + issues: List[str] = [] + + # ------------------------------------------------------------------- + # Physical margins (new model) — replaces the heuristic score/margins. [plan A4, M4] + # ------------------------------------------------------------------- + phys = None + try: + phys = compute_physical_stability(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg) + except Exception: # defensive: never fail the eval on a stability-model error + phys = None + + if phys is not None: + chug_margin = float(phys["chug_gate_margin"]) + acoustic_margin = float(phys["acoustic_gate_margin"]) + _fch = phys.get("f_chug_hz") + if _fch is not None and np.isfinite(_fch) and _fch > 0: + chugging["frequency"] = float(_fch) # physical chug freq, not L*/c* placeholder + chugging["stability_margin"] = chug_margin + chugging["chug_gain_margin"] = phys["chug"].get("gain_margin") + feed_stability["stability_margin"] = chug_margin # feed-coupled instability IS chug (un-rig) + if not phys["chug"].get("stable", True): + issues.append("Chug (feed-coupled LF) margin low: stiffen injector or improve atomization") + if not phys["acoustic"].get("stable", True): + issues.append(f"Acoustic mode {phys['acoustic'].get('limiting_mode')} driven (alpha>0)") + else: + # The physical model could not be evaluated: margins are UNKNOWN. Neutral-pass so a + # stability-model error never fails an evaluation, and say so in the issues list rather + # than reporting a heuristic as if it were a margin. + chug_margin = 1.10 + acoustic_margin = 1.10 + chugging["stability_margin"] = chug_margin + feed_stability["stability_margin"] = chug_margin + issues.append("Stability model could not be evaluated for this point; margins shown are neutral placeholders") + # ------------------------------------------------------------------- # Mode coupling analysis # ------------------------------------------------------------------- @@ -647,7 +661,9 @@ def comprehensive_stability_analysis( # Collect representative modes for coupling checks modes: List[Dict[str, Any]] = [] - modes.append({"name": "chugging", "type": "combustion", "frequency": chugging["frequency"]}) + # The physical chug frequency when the model ran; the geometric placeholder is not a chug mode. + if phys is not None and np.isfinite(chugging["frequency"]): + modes.append({"name": "chugging", "type": "combustion", "frequency": chugging["frequency"]}) modes.append({"name": "pogo", "type": "feed", "frequency": feed_stability["pogo_frequency"]}) modes.append({"name": "surge", "type": "feed", "frequency": feed_stability["surge_frequency"]}) @@ -683,9 +699,7 @@ def comprehensive_stability_analysis( # Stability classification # ------------------------------------------------------------------- - issues: List[str] = [] - - # NOTE: chug/acoustic issues come from the PHYSICAL model below (not the old heuristic chugging + # NOTE: chug/acoustic issues come from the PHYSICAL model above (not the old heuristic chugging # stability_index), and water-hammer is handled below as a separate valve-transient note. # Mode coupling @@ -696,34 +710,6 @@ def comprehensive_stability_analysis( if Lstar < 0.5 or Lstar > 3.0: issues.append(f"L* outside typical range (0.5 m to 3.0 m). Current L* = {Lstar:.2f} m") - # ------------------------------------------------------------------- - # Physical margins (new model) — replaces the heuristic score/margins. [plan A4, M4] - # ------------------------------------------------------------------- - phys = None - try: - phys = compute_physical_stability(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg) - except Exception: # defensive: never fail the eval on a stability-model error - phys = None - - if phys is not None: - chug_margin = float(phys["chug_gate_margin"]) - acoustic_margin = float(phys["acoustic_gate_margin"]) - _fch = phys.get("f_chug_hz") - if _fch is not None and np.isfinite(_fch) and _fch > 0: - chugging["frequency"] = float(_fch) # physical chug freq, not L*/c* placeholder - chugging["stability_margin"] = chug_margin - chugging["chug_gain_margin"] = phys["chug"].get("gain_margin") - feed_stability["stability_margin"] = chug_margin # feed-coupled instability IS chug (un-rig) - if not phys["chug"].get("stable", True): - issues.append("Chug (feed-coupled LF) margin low: stiffen injector or improve atomization") - if not phys["acoustic"].get("stable", True): - issues.append(f"Acoustic mode {phys['acoustic'].get('limiting_mode')} driven (alpha>0)") - else: - # fallback: do NOT regress if extraction fails — neutral-pass margins - chug_margin = float(chugging.get("stability_margin", 1.10)) - acoustic_margin = 1.10 - feed_stability["stability_margin"] = chug_margin - # Numeric score in [0,1] monotone in the limiting gate margin (1.05 ~ gate threshold). min_margin = min(chug_margin, acoustic_margin) score = float(np.clip((min_margin - 0.85) / 0.45, 0.0, 1.0)) @@ -793,9 +779,10 @@ def _generate_stability_recommendations( else: recs.append("System appears reasonably stable for this point. Still monitor during hot fire.") - # Chugging related - if chugging["stability_index"] < 0.5: - recs.append("Increase chamber pressure or L* to improve low frequency combustion stability.") + # Chug: Nyquist gain margin of the feed-coupled loop (>1 stable; <1.5 is thin) + gm = chugging.get("chug_gain_margin") + if gm is not None and np.isfinite(gm) and gm < 1.5: + recs.append("Chug gain margin is thin: stiffen the injector (raise dP_inj/Pc) or shorten the vaporization lag (finer SMD).") recs.append("Consider injector or chamber damping features such as baffles or acoustic liners.") if chugging["frequency"] < 10.0: diff --git a/EngineDesign/engine/pipeline/stability/core.py b/EngineDesign/engine/pipeline/stability/core.py index 531d1b0b9..9ec58eedd 100644 --- a/EngineDesign/engine/pipeline/stability/core.py +++ b/EngineDesign/engine/pipeline/stability/core.py @@ -29,6 +29,8 @@ "n_tau_gain", "choked_flow_function", "chamber_residence_time", + "area_ratio_from_mach", + "mach_from_area_ratio_subsonic", "spalding_transfer_number_heat", "d2_law_evaporation_constant", "vaporization_time", @@ -135,6 +137,35 @@ def chamber_residence_time(Lstar: float, cstar: float, gamma: float) -> float: return float(Lstar / (G * G * cstar)) +def area_ratio_from_mach(M: float, gamma: float) -> float: + """Isentropic area ratio ``A/A* = (1/M) * [(2/(g+1)) * (1 + (g-1)/2 * M^2)]^((g+1)/(2(g-1)))``.""" + g = float(gamma) + if M <= 0 or g <= 1.0: + return float("nan") + return float((1.0 / M) * ((2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M * M)) ** ((g + 1.0) / (2.0 * (g - 1.0)))) + + +def mach_from_area_ratio_subsonic(area_ratio: float, gamma: float) -> float: + """Subsonic Mach number at a station with ``A/A* = area_ratio`` (isentropic, one-dimensional). + + This is the mean Mach at the nozzle entrance when ``area_ratio`` is the contraction ratio + ``A_chamber / A_throat``, which is what sets the convective (nozzle) acoustic damping. Bisection + on [1e-6, 1]: A/A* is monotone decreasing in M on the subsonic branch. ``area_ratio <= 1`` -> 1.0. + """ + if not np.isfinite(area_ratio) or gamma <= 1.0: + return float("nan") + if area_ratio <= 1.0: + return 1.0 + lo, hi = 1e-6, 1.0 + for _ in range(80): + mid = 0.5 * (lo + hi) + if area_ratio_from_mach(mid, gamma) > area_ratio: + lo = mid # too subsonic: area ratio still above target -> raise M + else: + hi = mid + return float(0.5 * (lo + hi)) + + # --------------------------------------------------------------------------- # 4. Vaporization / time lag # --------------------------------------------------------------------------- diff --git a/EngineDesign/engine/pipeline/stability/enhanced.py b/EngineDesign/engine/pipeline/stability/enhanced.py deleted file mode 100644 index ee4bce8e3..000000000 --- a/EngineDesign/engine/pipeline/stability/enhanced.py +++ /dev/null @@ -1,383 +0,0 @@ -"""Enhanced physics-based stability analysis for pintle injectors. - -Accounts for: -1. Pintle geometry (tip diameter, length, gap) -2. Fuel impingement zones (localized instability sources) -3. Recirculation zones (flow patterns near pintle tip) -4. Pintle length effects (acoustic coupling) -5. Uneven ablation (spatial variation in geometry) -6. Real wave propagation physics with proper boundary conditions -""" - -from __future__ import annotations - -from typing import Dict, List, Tuple, Optional -import numpy as np -from engine.pipeline.config_schemas import PintleEngineConfig, PintleInjectorConfig -from engine.pipeline.localized_ablation import calculate_impingement_zones - - -def calculate_pintle_recirculation_zones( - L_pintle: float, - d_pintle_tip: float, - D_chamber: float, - L_chamber: float, - positions: np.ndarray, - fuel_velocity: float = 50.0, - lox_velocity: float = 30.0, -) -> Dict[str, np.ndarray]: - """ - Calculate recirculation zones near pintle tip. - - Physics: - - Fuel spray from pintle tip creates recirculation eddies - - LOX jets create additional recirculation - - Recirculation zones have different acoustic properties - - These zones affect wave propagation and stability - - Parameters: - ----------- - L_pintle : float - Pintle length [m] (distance from injector face to tip) - d_pintle_tip : float - Pintle tip diameter [m] - D_chamber : float - Chamber diameter [m] - L_chamber : float - Chamber length [m] - positions : np.ndarray - Axial positions [m] - fuel_velocity : float - Fuel injection velocity [m/s] - lox_velocity : float - LOX injection velocity [m/s] - - Returns: - -------- - recirculation : dict - - recirculation_intensity: Local recirculation intensity (0-1) - - recirculation_length: Characteristic recirculation length [m] - - velocity_fluctuation: Velocity fluctuation magnitude [m/s] - - turbulence_intensity: Turbulence intensity (0-1) - """ - n_points = len(positions) - - # Recirculation zone extends from injector face (x=0) to ~2-3x pintle length - L_recirc = 2.5 * L_pintle # Typical recirculation length - - # Recirculation intensity decays with distance from pintle tip - recirculation_intensity = np.zeros(n_points) - recirculation_length = np.zeros(n_points) - velocity_fluctuation = np.zeros(n_points) - turbulence_intensity = np.zeros(n_points) - - for i, x in enumerate(positions): - if x <= L_recirc: - # Recirculation zone: intensity decays exponentially - decay_factor = np.exp(-x / (0.5 * L_pintle)) - - # Physics-based recirculation intensity - from engine.pipeline.physics_based_replacements import calculate_recirculation_intensity_physics - - # Estimate Reynolds number - rho_approx = 5.0 # kg/m³, typical hot gas - mu_approx = 4e-5 # Pa·s - Re_injector = rho_approx * fuel_velocity * d_pintle_tip / mu_approx - - base_intensity = calculate_recirculation_intensity_physics( - fuel_velocity=fuel_velocity, - lox_velocity=lox_velocity, - d_pintle_tip=d_pintle_tip, - D_chamber=D_chamber, - Re_injector=Re_injector, - ) - - recirculation_intensity[i] = base_intensity * decay_factor - - # Characteristic recirculation length (eddy size) - # From turbulent mixing theory: L_eddy ~ 0.1-0.3 × injector size - # Depends on velocity ratio and Reynolds number - eddy_base = 0.2 * d_pintle_tip # Base eddy size - velocity_factor = 1.0 + 0.3 * (fuel_velocity / (lox_velocity + 1e-10) - 1.0) - recirculation_length[i] = eddy_base * velocity_factor * (1.0 + 0.2 * decay_factor) - - # Velocity fluctuations (RMS) from turbulence theory - # u' ~ 0.1-0.2 × U for turbulent flow - # Higher recirculation → higher fluctuations - v_fluct_base = 0.12 * fuel_velocity * (1.0 + base_intensity) # Physics-based - velocity_fluctuation[i] = v_fluct_base * decay_factor - - # Turbulence intensity from mixing theory - # I_turb ~ 0.1-0.3 for recirculating flows - # Depends on velocity ratio and recirculation intensity - base_turbulence = 0.1 + 0.1 * base_intensity # Physics-based - velocity_enhancement = 1.0 + 0.2 * (fuel_velocity / (lox_velocity + 1e-10) - 1.0) - turbulence_intensity[i] = base_turbulence * decay_factor * velocity_enhancement - else: - # Outside recirculation zone - recirculation_intensity[i] = 0.0 - recirculation_length[i] = 0.0 - velocity_fluctuation[i] = 0.0 - turbulence_intensity[i] = 0.05 # Base turbulence - - return { - "recirculation_intensity": recirculation_intensity, - "recirculation_length": recirculation_length, - "velocity_fluctuation": velocity_fluctuation, - "turbulence_intensity": turbulence_intensity, - } - - -def calculate_pintle_stability_enhanced( - config: PintleEngineConfig, - positions: np.ndarray, - chamber_pressure: np.ndarray, - sound_speed: np.ndarray, - density: np.ndarray, - mass_flow: np.ndarray, - recession_profile: Optional[np.ndarray] = None, - L_chamber: float = 0.2, - D_chamber: float = 0.1, - fuel_velocity: float = 50.0, - lox_velocity: float = 30.0, -) -> Dict[str, np.ndarray]: - """ - Enhanced stability calculation with full pintle physics. - - Physics: - 1. Pintle geometry affects injector impedance and acoustic coupling - 2. Fuel impingement creates localized pressure fluctuation sources - 3. Recirculation zones create acoustic damping/amplification - 4. Pintle length affects acoustic mode coupling - 5. Uneven ablation creates impedance mismatches - 6. Wave propagation with proper boundary conditions - - Parameters: - ----------- - config : PintleEngineConfig - Engine configuration - positions : np.ndarray - Axial positions [m] - chamber_pressure : np.ndarray - Local pressure [Pa] - sound_speed : np.ndarray - Local sound speed [m/s] - density : np.ndarray - Local density [kg/m³] - mass_flow : np.ndarray - Local mass flow [kg/s] - recession_profile : np.ndarray, optional - Local recession [m] at each position (for uneven ablation) - L_chamber : float - Chamber length [m] - D_chamber : float - Chamber diameter [m] - fuel_velocity : float - Fuel injection velocity [m/s] - lox_velocity : float - LOX injection velocity [m/s] - - Returns: - -------- - stability : dict - - chugging_frequency: Local chugging frequency [Hz] - - stability_margin: Local stability margin - - wave_growth_rate: Wave growth rate [1/s] - - impingement_effect: Effect of impingement on stability - - recirculation_effect: Effect of recirculation on stability - - ablation_effect: Effect of uneven ablation on stability - - pintle_length_effect: Effect of pintle length on acoustic coupling - """ - n_points = len(positions) - - # Get pintle geometry - if not hasattr(config, 'injector') or config.injector.type != "pintle": - # No pintle-specific effects - return { - "chugging_frequency": np.full(n_points, 30.0), - "stability_margin": np.full(n_points, 0.5), - "wave_growth_rate": np.full(n_points, -10.0), - "impingement_effect": np.zeros(n_points), - "recirculation_effect": np.zeros(n_points), - "ablation_effect": np.zeros(n_points), - "pintle_length_effect": np.zeros(n_points), - } - - injector_config: PintleInjectorConfig = config.injector - geometry = injector_config.geometry - - # Pintle geometry parameters - d_pintle_tip = geometry.fuel.d_pintle_tip - h_gap = geometry.fuel.h_gap - L_pintle = getattr(geometry.fuel, 'L_pintle', 0.01) # Pintle length [m] - n_orifices = geometry.lox.n_orifices - d_orifice = geometry.lox.d_orifice - theta_orifice = geometry.lox.theta_orifice - - # Calculate impingement zones (where fuel hits wall) - impingement_data = calculate_impingement_zones( - config, L_chamber, D_chamber, n_points=n_points - ) - impingement_multiplier = impingement_data["impingement_heat_flux_multiplier"] - impingement_zones = impingement_data["impingement_zones"] - impingement_center = impingement_data.get("impingement_center", L_chamber * 0.7) - - # Calculate recirculation zones (near pintle tip) - recirculation_data = calculate_pintle_recirculation_zones( - L_pintle, d_pintle_tip, D_chamber, L_chamber, positions, - fuel_velocity, lox_velocity - ) - recirculation_intensity = recirculation_data["recirculation_intensity"] - recirculation_length = recirculation_data["recirculation_length"] - velocity_fluctuation = recirculation_data["velocity_fluctuation"] - turbulence_intensity = recirculation_data["turbulence_intensity"] - - # Calculate injector impedance from pintle geometry - # Acoustic impedance: Z = ρ × c / A - A_pintle_tip = np.pi * (d_pintle_tip / 2.0) ** 2 - A_gap = np.pi * d_pintle_tip * h_gap # Annular gap area - A_injector_effective = A_pintle_tip + A_gap - - # Injector impedance (at injection plane) - rho_injector = density[0] if len(density) > 0 else 1000.0 - c_injector = sound_speed[0] if len(sound_speed) > 0 else 1000.0 - Z_injector = rho_injector * c_injector / A_injector_effective if A_injector_effective > 0 else 1e6 - - # Feed system impedance (simplified) - Z_feed = 5e5 # Typical feed system impedance [Pa·s/m³] - - # Calculate local impedances - A_local = np.pi * (D_chamber / 2.0) ** 2 - Z_local = density * sound_speed / A_local - - # Wave propagation time - L_total = positions[-1] - positions[0] if len(positions) > 1 else L_chamber - tau_wave = L_total / sound_speed # Wave propagation time - - # Base chugging frequency from wave resonance - # f = c / (4L) for open-closed tube (injector closed, throat open) - f_chugging_base = sound_speed / (4.0 * L_total) - - # Pintle length effect on frequency - # Longer pintle = different acoustic coupling = frequency shift - # Pintle acts as acoustic extension of injector - L_effective = L_total + 0.3 * L_pintle # Effective length includes pintle - f_chugging_pintle = sound_speed / (4.0 * L_effective) - - # Frequency shift from pintle geometry - pintle_ratio = d_pintle_tip / D_chamber if D_chamber > 0 else 0.1 - frequency_shift = 1.0 + 0.15 * (pintle_ratio - 0.1) + 0.1 * (L_pintle / L_chamber) - f_chugging = f_chugging_pintle * frequency_shift - - # Pintle length effect on acoustic coupling - # Longer pintle = stronger coupling between injector and chamber - coupling_strength = 1.0 + 0.5 * (L_pintle / L_chamber) # Stronger coupling - pintle_length_effect = (coupling_strength - 1.0) * 0.3 # Can be stabilizing or destabilizing - - # Impingement effect on stability - # Fuel impingement creates localized pressure fluctuation sources - # These act as instability sources - impingement_effect = np.zeros(n_points) - for i, (pos, is_impingement) in enumerate(zip(positions, impingement_zones)): - if is_impingement: - # Impingement creates pressure fluctuation source - # Effect decays with distance from impingement - distance_from_impingement = abs(pos - impingement_center) - decay_factor = np.exp(-distance_from_impingement / (L_chamber * 0.1)) - # Impingement multiplier indicates intensity - intensity = (impingement_multiplier[i] - 1.0) * 0.5 # Destabilizing - impingement_effect[i] = intensity * decay_factor - - # Recirculation effect on stability - # Recirculation zones can: - # 1. Damp waves (turbulence dissipation) - # 2. Amplify waves (resonance in eddies) - # Net effect depends on recirculation intensity and turbulence - recirculation_effect = np.zeros(n_points) - for i in range(n_points): - if recirculation_intensity[i] > 0: - # Recirculation creates velocity fluctuations - # These can couple with pressure waves - # High turbulence = damping (stabilizing) - # Low turbulence + high intensity = amplification (destabilizing) - turbulence_damping = turbulence_intensity[i] * 0.5 # Stabilizing - recirculation_amplification = recirculation_intensity[i] * (1.0 - turbulence_intensity[i]) * 0.3 # Destabilizing - recirculation_effect[i] = recirculation_amplification - turbulence_damping - - # Uneven ablation effect - # Spatial variation in geometry creates impedance mismatches - # These reflect waves and can cause instability - ablation_effect = np.zeros(n_points) - if recession_profile is not None and len(recession_profile) == n_points: - # Calculate local diameter variation - D_local = D_chamber + 2.0 * recession_profile - A_local_varying = np.pi * (D_local / 2.0) ** 2 - - # Impedance variation - Z_varying = density * sound_speed / A_local_varying - - # Impedance mismatch creates reflections - # Large mismatch = more reflections = potential instability - Z_ref = Z_local[0] if len(Z_local) > 0 else Z_local.mean() - impedance_mismatch = np.abs(Z_varying - Z_ref) / (Z_ref + 1e-10) - ablation_effect = impedance_mismatch * 0.3 # Destabilizing effect - - # Wave growth rate from energy balance - # Energy input from combustion vs. energy dissipation - energy_input = chamber_pressure * mass_flow / density # [W/m³] - energy_dissipation = density * (mass_flow / (density * A_local)) ** 2 / L_total # [W/m³] - energy_stored = 0.5 * density * sound_speed ** 2 # [J/m³] - - # Base growth rate from energy balance - # For well-designed engines, dissipation > input (net damping) - energy_balance = (energy_input - energy_dissipation) / (2.0 * energy_stored + 1e-10) - - # Base damping rate: well-designed engines have negative growth (damping) - # Typical damping: -20 to -100 [1/s] for stable engines - # Add base damping from acoustic losses, wall friction, etc. - base_damping = -50.0 # [1/s] - base damping rate (negative = stable) - - # Energy balance modifies base damping - # Positive energy balance (input > dissipation) = destabilizing - # Negative energy balance (dissipation > input) = stabilizing - wave_growth_base = base_damping + energy_balance * 10.0 # Scale energy balance effect - - # Add all effects - # Impingement: destabilizing (reduces damping) - # Recirculation: can be stabilizing (turbulence damping) or destabilizing (amplification) - # Ablation: destabilizing (impedance mismatch) - # Pintle length: usually stabilizing (better mixing = more damping) - wave_growth_rate = ( - wave_growth_base - + impingement_effect # Destabilizing (reduces damping) - + recirculation_effect # Can be stabilizing or destabilizing - - ablation_effect * 0.5 # Destabilizing (impedance mismatch) - - pintle_length_effect * 0.3 # Usually stabilizing (better mixing) - ) - - # Stability margin: positive = stable, negative = unstable - # For stability: wave_growth_rate should be negative (damping) - # Margin = (damping_rate - growth_rate) / reference_rate - # Higher margin = more stable - reference_rate = 100.0 # [1/s] - reference growth rate - damping_rate = -wave_growth_rate # Convert growth to damping - stability_margin = damping_rate / reference_rate - - # Clamp to reasonable range: -2 to +2 - # Positive = stable, negative = unstable - stability_margin = np.clip(stability_margin, -2.0, 2.0) - - return { - "chugging_frequency": f_chugging, - "stability_margin": stability_margin, - "wave_growth_rate": wave_growth_rate, - "impingement_effect": impingement_effect, - "recirculation_effect": recirculation_effect, - "ablation_effect": ablation_effect, - "pintle_length_effect": pintle_length_effect, - "recirculation_intensity": recirculation_intensity, - "turbulence_intensity": turbulence_intensity, - "pintle_ratio": pintle_ratio, - "frequency_shift": frequency_shift, - } - diff --git a/EngineDesign/engine/pipeline/stability/report.py b/EngineDesign/engine/pipeline/stability/report.py index a6f1c64bf..0a008d572 100644 --- a/EngineDesign/engine/pipeline/stability/report.py +++ b/EngineDesign/engine/pipeline/stability/report.py @@ -64,8 +64,15 @@ def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Di L_ch = inp["L_ch"] rho_O = float(inp.get("rho_O", 1140.0)) # config-sourced via build_stability_inputs (P2c) eta = inp["eta_inj_O"] - # representative droplet axial speed ~ LOX injection velocity v=sqrt(2*dP/rho) (Cd~0.6) - v_drop = 0.6 * float(np.sqrt(max(2.0 * eta * Pc / rho_O, 1.0))) + # Representative droplet axial speed: the solved oxidizer injection velocity when the closure + # provides it, else Bernoulli with the solved Cd (a fixed Cd of 0.6 used to sit here). + u_O = inp.get("u_O") + if u_O is not None and np.isfinite(float(u_O)) and float(u_O) > 0.0: + v_drop = float(u_O) + else: + Cd = inp.get("Cd_O") + Cd = float(Cd) if (Cd is not None and np.isfinite(float(Cd)) and float(Cd) > 0.0) else 0.6 + v_drop = Cd * float(np.sqrt(max(2.0 * eta * Pc / rho_O, 1.0))) tau_vap = inp["tau_conv_O"] L_vap = v_drop * tau_vap if np.isfinite(tau_vap) else float("nan") x_max = float(max(L_ch, L_vap if np.isfinite(L_vap) else L_ch) * 1.1) @@ -83,11 +90,11 @@ def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Di def _sensitivity(inp: Dict[str, Any]) -> Dict[str, Any]: """n / chi sensitivity bands for the acoustic limiting-mode growth rate (cheap sweep).""" - D_ch, L_ch, gas = inp["D_ch"], inp["L_ch"], inp["gas"] + D_ch, L_ch, gas, coeffs = inp["D_ch"], inp["L_ch"], inp["gas"], inp["damping_coeffs"] tv = inp["tau_conv_O"] - a_n = [acoustic.fast_acoustic(D_ch, L_ch, gas, n=nn, tau_sens=inp["tau_sens"])["alpha_max"] + a_n = [acoustic.fast_acoustic(D_ch, L_ch, gas, n=nn, tau_sens=inp["tau_sens"], coeffs=coeffs)["alpha_max"] for nn in (0.3, 0.6)] - a_chi = [acoustic.fast_acoustic(D_ch, L_ch, gas, n=inp["n_interaction"], tau_sens=cc * tv)["alpha_max"] + a_chi = [acoustic.fast_acoustic(D_ch, L_ch, gas, n=inp["n_interaction"], tau_sens=cc * tv, coeffs=coeffs)["alpha_max"] for cc in (0.05, 0.30)] return {"acoustic_alpha_vs_n": [float(min(a_n)), float(max(a_n))], "acoustic_alpha_vs_chi": [float(min(a_chi)), float(max(a_chi))]} @@ -103,7 +110,7 @@ def _chug_pole(chug_rich: Dict[str, Any]) -> Dict[str, float]: def _radar(chug_margin: float, ac: Dict[str, Any], vap: Dict[str, Any], - gate_threshold: float) -> Dict[str, Any]: + gate_threshold: float, alpha_offset: float) -> Dict[str, Any]: """Viz #7: one-glance health radar.""" def mode_alpha(name): for m in ac["modes"]: @@ -112,8 +119,8 @@ def mode_alpha(name): return float("-inf") a1L, a1T = mode_alpha("1L"), mode_alpha("1T") # normalize alphas to a 0..1.3 "margin-like" scale via the same acoustic gate mapping - v1L = analysis._acoustic_gate_margin(a1L) - v1T = analysis._acoustic_gate_margin(a1T) + v1L = analysis._acoustic_gate_margin(a1L, alpha_offset) + v1T = analysis._acoustic_gate_margin(a1T, alpha_offset) vap_complete = float(np.clip(vap["L_ch_m"] / vap["L_vap_m"], 0.0, 1.3)) if ( np.isfinite(vap["L_vap_m"]) and vap["L_vap_m"] > 0) else 1.3 axes = ["chug", "1L", "1T", "vaporization"] @@ -272,9 +279,10 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl # --- acoustic (full mode set with damping budgets) --- ac = acoustic.analyze_acoustic_modes(inp["D_ch"], inp["L_ch"], gas, - n=inp["n_interaction"], tau_sens=inp["tau_sens"]) + n=inp["n_interaction"], tau_sens=inp["tau_sens"], + coeffs=inp["damping_coeffs"]) ac_alpha_max = ac["modes"][0]["alpha"] if ac["modes"] else float("nan") - acoustic_margin = analysis._acoustic_gate_margin(ac_alpha_max) + acoustic_margin = analysis._acoustic_gate_margin(ac_alpha_max, inp["acoustic_gate_alpha_offset"]) acoustic_modes = [{ "name": m["mode"], "freq_hz": m["f_hz"], "alpha": m["alpha"], "driving": m["driving"], @@ -288,7 +296,7 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl vap = _vaporization_profile(inp, Pc) sens = _sensitivity(inp) - radar = _radar(chug_margin, ac, vap, gate_threshold) + radar = _radar(chug_margin, ac, vap, gate_threshold, inp["acoustic_gate_alpha_offset"]) min_margin = float(min(chug_margin, acoustic_margin)) state = ("stable" if (chug_margin >= gate_threshold and acoustic_margin >= gate_threshold @@ -320,6 +328,10 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl "dP_reg_max_psi": float(streams[0].regulator.max_excursion_pa / _PA_PER_PSI), "eta_inj_O": inp["eta_inj_O"], "eta_inj_F": inp["eta_inj_F"], "smd_O_um": float(inp["D32_O"] * 1e6), + "mach_nozzle_entrance": float(inp["mach_nozzle_entrance"]), + "contraction_ratio": float(inp["contraction_ratio"]), + "feed_length_O_m": float(inp["feed_length_O"]), "feed_length_F_m": float(inp["feed_length_F"]), + "acoustic_gate_alpha_offset": float(inp["acoustic_gate_alpha_offset"]), # Every recorded silent-default substitution this process has made (P2c registry). # Empty list = config fully specified the physics. The hardcoded-Cd bug class, surfaced. "fallbacks_used": _fallbacks_used(), diff --git a/EngineDesign/engine/pipeline/time_varying_solver.py b/EngineDesign/engine/pipeline/time_varying_solver.py index ea11d740d..fd367a861 100644 --- a/EngineDesign/engine/pipeline/time_varying_solver.py +++ b/EngineDesign/engine/pipeline/time_varying_solver.py @@ -35,7 +35,6 @@ from engine.pipeline.stability.analysis import ( calculate_chugging_frequency, calculate_acoustic_modes, - analyze_feed_system_stability, # Correct function name ) from engine.pipeline.thermal.regen_cooling import estimate_hot_wall_heat_flux @@ -718,109 +717,34 @@ def solve_time_step( "hotspot_max_intensity": 1.0, } - # Calculate stability with pintle geometry, impingement, and recirculation - # Use enhanced physics-based spatial stability analysis - # NOTE (UNIFICATION P3, FINDING F3): calculate_pintle_stability_enhanced returns a flat - # PLACEHOLDER (margin 0.5, 30 Hz) for non-pintle injectors. The scalars stability_margin and - # chugging_freq are overwritten below by the injector-agnostic comprehensive_stability_analysis, - # but feed_stability["stability_margin"] and the spatial `acoustic` retain placeholder values - # for non-pintle. Clean separation deferred to the legacy_pintle/ refactor (see CONTEXT.md). - try: - from engine.pipeline.stability.enhanced import calculate_pintle_stability_enhanced - from engine.pipeline.localized_ablation import calculate_impingement_zones - - # Create position array for spatial analysis - n_stability_points = 50 - positions_stability = np.linspace(0.0, self.L_chamber, n_stability_points) - - # Calculate local properties (simplified - assume uniform for now) - P_local = np.full(n_stability_points, Pc) - c_local = np.full(n_stability_points, np.sqrt(gamma_chamber * R_chamber * Tc)) - rho_local = np.full(n_stability_points, Pc / (R_chamber * Tc)) - mdot_local = np.full(n_stability_points, mdot_total) - - # Recession profile (spatial variation) - recession_profile = None - if ablative_cfg and ablative_cfg.enabled: - # Create spatial recession profile (more at impingement zones) - impingement_data = calculate_impingement_zones( - config_current, self.L_chamber, D_chamber_new, n_points=n_stability_points - ) - # Recession is enhanced at impingement zones - recession_base = recession_chamber_new - recession_profile = recession_base * impingement_data["impingement_heat_flux_multiplier"] - - # Get injection velocities for recirculation calculation - # These would come from injector solve, but use estimates for now - fuel_velocity = 50.0 # [m/s] - typical fuel injection velocity - lox_velocity = 30.0 # [m/s] - typical LOX injection velocity - - # Calculate enhanced pintle-based stability with recirculation - stability_spatial = calculate_pintle_stability_enhanced( - config_current, - positions_stability, - P_local, - c_local, - rho_local, - mdot_local, - recession_profile=recession_profile, - L_chamber=self.L_chamber, - D_chamber=D_chamber_new, - fuel_velocity=fuel_velocity, - lox_velocity=lox_velocity, - ) - - # Use average values for single-point metrics - chugging_freq = float(np.mean(stability_spatial["chugging_frequency"])) - stability_margin = float(np.mean(stability_spatial["stability_margin"])) - - # Acoustic modes (use base calculation for now, could be enhanced) - acoustic = calculate_acoustic_modes( - self.L_chamber, - D_chamber_new, - gamma_chamber, - R_chamber, - Tc, - ) - - # Feed system stability - feed_stability = { - "pogo_frequency": np.nan, - "surge_frequency": np.nan, - "stability_margin": stability_margin, - } - - except Exception as e: - # Fallback to simple calculation - import warnings - warnings.warn(f"Pintle stability calculation failed, using fallback: {e}") - chugging = calculate_chugging_frequency( - V_chamber_new, - A_throat_new, - cstar_actual, - gamma_chamber, - Pc, - R=R_chamber, - Tc=Tc, - ) - chugging_freq = chugging["frequency"] - # CRITICAL FIX: Remove arbitrary 0.5 default - stability margin should be calculated - # If not available, use neutral (0.0) rather than arbitrary positive value - stability_margin = chugging.get("stability_margin", 0.0) # Neutral if unknown - - acoustic = calculate_acoustic_modes( - self.L_chamber, - D_chamber_new, - Tc, - gamma_chamber, - R_chamber, - ) - - feed_stability = { - "pogo_frequency": np.nan, - "surge_frequency": np.nan, - "stability_margin": 1.0, - } + # Stability. The injector-agnostic comprehensive analysis below is authoritative; these + # are the placeholders it overwrites, kept so the state record is always populated even + # when that analysis raises. (The old pintle-only "enhanced" spatial model that used to + # run here was fed hardcoded 50/30 m/s injection velocities and invented damping + # constants, and every scalar it produced was overwritten anyway -- removed.) + chugging = calculate_chugging_frequency( + V_chamber_new, + A_throat_new, + cstar_actual, + gamma_chamber, + Pc, + R=R_chamber, + Tc=Tc, + ) + chugging_freq = chugging["frequency"] + stability_margin = float("nan") + acoustic = calculate_acoustic_modes( + self.L_chamber, + D_chamber_new, + Tc, + gamma_chamber, + R_chamber, + ) + feed_stability = { + "pogo_frequency": np.nan, + "surge_frequency": np.nan, + "stability_margin": np.nan, + } # Use comprehensive stability analysis if available comprehensive_stability = None @@ -852,9 +776,11 @@ def solve_time_step( diagnostics=stability_diag, ) - # Update stability_margin from comprehensive analysis + # The comprehensive analysis owns every stability scalar in the state record. stability_margin = comprehensive_stability.get("chugging", {}).get("stability_margin", stability_margin) chugging_freq = comprehensive_stability.get("chugging", {}).get("frequency", chugging_freq) + acoustic = comprehensive_stability.get("acoustic", acoustic) + feed_stability = comprehensive_stability.get("feed_system", feed_stability) except Exception as e: import warnings warnings.warn(f"Comprehensive stability analysis failed: {e}") diff --git a/EngineDesign/engine/stability_hifi/__init__.py b/EngineDesign/engine/stability_hifi/__init__.py new file mode 100644 index 000000000..a6d022f53 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/__init__.py @@ -0,0 +1,13 @@ +"""High-fidelity combustion stability suite (thermoacoustic global modes). + +New, self-contained package implementing the formulation of +``docs/stability/thermoacoustic_global_stability_paper.md``. Does not import from or +modify ``engine/pipeline/stability/`` (the lumped model), which remains the in-loop +screen and the authoritative chug/feed-coupled analysis. + +Phasing (paper Section IX): + P0 eigensolver core on synthetic mean flows <- current + P1 real chamber geometry + parametric mean flow + P2 CFD-anchored (SU2) mean flow + P3 hardening (Beyn audit, SLEPc NEP, validation cases) +""" diff --git a/EngineDesign/engine/stability_hifi/acoustics/__init__.py b/EngineDesign/engine/stability_hifi/acoustics/__init__.py new file mode 100644 index 000000000..8ef25949b --- /dev/null +++ b/EngineDesign/engine/stability_hifi/acoustics/__init__.py @@ -0,0 +1,2 @@ +"""Meridional (2-D, axisymmetric) meshing and FEM assembly for the thermoacoustic +Helmholtz operator (paper Section III.F-G).""" diff --git a/EngineDesign/engine/stability_hifi/acoustics/assembly.py b/EngineDesign/engine/stability_hifi/acoustics/assembly.py new file mode 100644 index 000000000..06cbb3d93 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/acoustics/assembly.py @@ -0,0 +1,336 @@ +"""FEM assembly of the passive (no flame, no boundary impedance) axisymmetric +thermoacoustic operator: the ``K``, ``Km``, ``M2`` matrices of Eq. (10)/(11) in +``docs/stability/thermoacoustic_global_stability_paper.md``. + + K = int_Omega c^2 (grad p . grad phi) r dOmega stiffness + Km = int_Omega (c^2 / r) p phi dOmega azimuthal term (x m^2) + M2 = int_Omega p phi r dOmega mass + +With these, the passive discrete eigenproblem (flame off, rigid walls: C=0, F=0) is + + (K + m^2 Km) p = -lambda^2 M2 p + +a *linear* generalized eigenvalue problem in mu = -lambda^2 (real, >= 0 for a lossless +rigid-wall cavity, since K, Km, M2 are all real symmetric positive-(semi)definite) — +see ``eigen/passive.py``. + +Why K and M2 have exact closed forms but Km does not +----------------------------------------------------- +On a P1 (linear) triangle, the shape functions N_i are the barycentric coordinates +L_i, and grad(N_i) is *constant* over the element. The radial coordinate r(x) is +itself a linear (degree-1) function of position, since it is a coordinate. So: + + - K's integrand, c^2 * (grad N_i . grad N_j) * r, is (const) * (linear in r) -> + integrable exactly by the 1-point centroid rule: int_T r dA = Area * r_centroid. + + - M2's integrand, N_i * N_j * r, is a *cubic* polynomial (degree 2 from N_i N_j, + degree 1 from r). Triangle integrals of monomials in barycentric coordinates have + a standard closed form (see ``_mass_r_element`` below), so M2 is also exact. + + - Km's integrand, N_i * N_j / r, is *not* polynomial (r appears in the denominator) + so no closed form exists; it is evaluated with a 6-point Gauss quadrature rule + (exact for polynomials up to degree 4), which is far more resolution than the + (non-polynomial) 1/r factor needs for engineering accuracy, and cheap since it + only touches the m>=1 (transverse) matrix. + +Mean-flow fields (``c``, i.e. sound speed) are supplied per-node and averaged over the +three vertices of an element to get a single c^2 per element — piecewise-constant per +element, consistent with the P1 discretization. +""" + +from __future__ import annotations + +import math +from typing import Tuple + +import numpy as np +from scipy import sparse + +from engine.stability_hifi.acoustics.mesh import MeridionalMesh + + +# --------------------------------------------------------------------------- +# Quadrature and element-level geometry +# --------------------------------------------------------------------------- + +# 6-point symmetric Gauss quadrature on the reference triangle, exact to degree 4 +# (Dunavant 1985). Rows are barycentric coordinates (lambda1, lambda2, lambda3) of +# the quadrature point; weights sum to 1 (fraction-of-area convention, so a physical +# integral is `area * sum_q w_q * f(point_q)`). +_GAUSS6_BARY = np.array([ + [0.108103018168070, 0.445948490915965, 0.445948490915965], + [0.445948490915965, 0.108103018168070, 0.445948490915965], + [0.445948490915965, 0.445948490915965, 0.108103018168070], + [0.816847572980459, 0.091576213509771, 0.091576213509771], + [0.091576213509771, 0.816847572980459, 0.091576213509771], + [0.091576213509771, 0.091576213509771, 0.816847572980459], +]) +_GAUSS6_W = np.array([ + 0.223381589678011, 0.223381589678011, 0.223381589678011, + 0.109951743655322, 0.109951743655322, 0.109951743655322, +]) + + +def _element_geometry(v: np.ndarray) -> Tuple[float, np.ndarray]: + """Triangle area and constant P1 shape-function gradients. + + ``v`` is (3, 2): vertex (x, r) coordinates. Returns ``(area, grad)`` with + ``grad[i] = (dN_i/dx, dN_i/dr)``, constant over the element (standard P1 formula). + """ + x = v[:, 0] + r = v[:, 1] + two_area = x[0] * (r[1] - r[2]) + x[1] * (r[2] - r[0]) + x[2] * (r[0] - r[1]) + area = 0.5 * abs(two_area) + b = np.array([r[1] - r[2], r[2] - r[0], r[0] - r[1]]) + c = np.array([x[2] - x[1], x[0] - x[2], x[1] - x[0]]) + grad = np.column_stack([b, c]) / two_area + return area, grad + + +# Exact triangle-integral tensor for int L_i L_j L_k dA, from the standard formula +# int_T L1^a L2^b L3^c dA = a! b! c! / (a+b+c+2)! * 2*Area . +# For a triple of shape-function indices (i, j, k) drawn from {0,1,2} (repeats +# allowed), (a, b, c) is just the multiplicity of label 0, 1, 2 among the triple, so +# the value depends only on how many of (i, j, k) coincide: +# all three equal -> 3!0!0!/5! * 2A = A/10 +# exactly two equal -> 2!1!0!/5! * 2A = A/30 +# all three distinct -> 1!1!1!/5! * 2A = A/60 +def _triple_integral_factor(i: int, j: int, k: int) -> float: + counts = [0, 0, 0] + for idx in (i, j, k): + counts[idx] += 1 + prod = math.factorial(counts[0]) * math.factorial(counts[1]) * math.factorial(counts[2]) + return prod / math.factorial(3 + 2) # = prod/120; multiply by 2*Area for the integral + + +_TRIPLE_FACTOR = np.array( + [[[_triple_integral_factor(i, j, k) for k in range(3)] for j in range(3)] for i in range(3)] +) # (3,3,3); integral = 2*Area * _TRIPLE_FACTOR[i,j,k] + + +def _mass_r_element(area: float, r_vertices: np.ndarray) -> np.ndarray: + """Exact element mass matrix ``int_T N_i N_j r dA`` (r linear -> cubic integrand). + + Expand ``r = sum_k N_k r_k`` and use the exact triple-product triangle integral. + """ + # Me[i,j] = sum_k r_k * (2*Area * _TRIPLE_FACTOR[i,j,k]) + return 2.0 * area * np.tensordot(_TRIPLE_FACTOR, r_vertices, axes=([2], [0])) + + +def _stiffness_r_element(area: float, grad: np.ndarray, r_vertices: np.ndarray) -> np.ndarray: + """Exact element stiffness ``int_T (grad N_i . grad N_j) r dA`` (centroid rule). + + ``grad`` is constant over the element, and ``r`` is linear, so the 1-point + centroid rule (`int_T r dA = Area * mean(r_vertices)`) is exact. + """ + r_bar = float(np.mean(r_vertices)) + return area * r_bar * (grad @ grad.T) + + +def _azimuthal_element(area: float, r_vertices: np.ndarray) -> np.ndarray: + """Element ``int_T N_i N_j / r dA`` via 6-point Gauss quadrature (no closed form).""" + Ke = np.zeros((3, 3)) + for bary, w in zip(_GAUSS6_BARY, _GAUSS6_W): + r_q = float(bary @ r_vertices) + if r_q <= 0.0: + continue # quadrature point exactly on the axis: 1/r term vanishes weakly + Ke += (w * area / r_q) * np.outer(bary, bary) + return Ke + + +# --------------------------------------------------------------------------- +# Assembly +# --------------------------------------------------------------------------- + +def assemble_passive(mesh: MeridionalMesh, c_sound: np.ndarray, m: int + ) -> Tuple[sparse.csr_matrix, sparse.csr_matrix, sparse.csr_matrix]: + """Assemble the passive (flame-off, rigid-wall) K, Km, M2 matrices (Eq. 10, 11). + + ``c_sound`` is the sound-speed field [m/s], given EITHER per-node (shape + ``(n_nodes,)`` — the mean flow varies smoothly, e.g. V1's uniform field or a real + CFD/parametric temperature profile; each element's c^2 is the mean of its 3 vertex + values, i.e. the P1 linear interpolant evaluated at the centroid) OR per-element + (shape ``(n_tri,)`` — the material is piecewise-constant across a genuine interface + that a shared node cannot represent, e.g. V2's temperature-jump duct; each element's + c^2 is used directly, with no averaging across the interface). See + ``_element_c_squared`` above for why this distinction matters physically. + + ``m`` is the azimuthal wavenumber; ``Km`` is returned *without* the m^2 factor + (caller multiplies when assembling ``K + m^2 * Km``), so the m=0 matrix (which has + no 1/r term at all) can be skipped cheaply by callers that only need + longitudinal/radial modes. + + Returns ``(K, Km, M2)`` as real symmetric sparse CSR matrices of size n_nodes^2. + Axis-regularity Dirichlet elimination for m>=1 is the caller's job (``eigen/passive.py``), + since it depends on how the reduced system is solved, not on the assembly. + """ + n = mesh.n_nodes + rows, cols, vK, vKm, vM = [], [], [], [], [] + + need_azimuthal = (m != 0) + + c_sound = np.asarray(c_sound) + if c_sound.shape[0] == mesh.n_nodes: + per_element_c2 = None # computed inside the loop, by averaging vertex values + elif c_sound.shape[0] == mesh.n_tri: + per_element_c2 = c_sound ** 2 # already one value per element; use directly + else: + raise ValueError( + f"c_sound has length {c_sound.shape[0]}, expected n_nodes={mesh.n_nodes} " + f"(smooth per-node field) or n_tri={mesh.n_tri} (piecewise-constant per-element field)" + ) + + for e, tri in enumerate(mesh.triangles): + v = mesh.nodes[tri] # (3, 2) + r_vertices = v[:, 1] + area, grad = _element_geometry(v) + if area <= 0.0: + raise ValueError("degenerate (zero-area) triangle in mesh") + + c2_e = float(per_element_c2[e]) if per_element_c2 is not None else float(np.mean(c_sound[tri]) ** 2) + + Ke = c2_e * _stiffness_r_element(area, grad, r_vertices) + Me = _mass_r_element(area, r_vertices) + Kme = c2_e * _azimuthal_element(area, r_vertices) if need_azimuthal else np.zeros((3, 3)) + + for a in range(3): + for b in range(3): + rows.append(tri[a]); cols.append(tri[b]) + vK.append(Ke[a, b]); vKm.append(Kme[a, b]); vM.append(Me[a, b]) + + K = sparse.coo_matrix((vK, (rows, cols)), shape=(n, n)).tocsr() + Km = sparse.coo_matrix((vKm, (rows, cols)), shape=(n, n)).tocsr() + M2 = sparse.coo_matrix((vM, (rows, cols)), shape=(n, n)).tocsr() + return K, Km, M2 + + +# --------------------------------------------------------------------------- +# Boundary admittance term C (Eq. 8, 10) — new for verification case V3 +# --------------------------------------------------------------------------- +# +# Section III.E, Eq. (8): a Robin condition grad(p_hat).n = -(lambda / (c*z)) * p_hat +# on a boundary patch with specific impedance z (admittance y = 1/z). In the weak form +# (Eq. 10) this becomes a boundary integral that, after moving everything to one side, +# contributes a term "+ lambda * C" to the discrete operator N(lambda), where +# +# C = int_Gamma (c_bar / z) p_tilde phi_bar r dGamma = int_Gamma (c_bar * y) ... r dGamma +# +# For the *compact* choked-nozzle admittance (Appendix C), y = y_noz = (gamma-1)*Mbar_e/2 +# is a REAL CONSTANT (no dependence on lambda or position along the boundary), so C here +# is just a fixed real matrix -- the general C(lambda) notation in the paper allows for a +# frequency-dependent z(lambda) (e.g. the quasi-1D nozzle admittance ODE, Section III.E), +# which is not needed for this compact case. +# +# Geometrically the boundary here is the disk at x = L (all r in [0, R]): revolved about +# the axis, a "boundary edge" of the meridional mesh is a radial line segment between two +# adjacent boundary nodes, and dGamma = dr along it (x fixed). The r-weighted edge mass +# matrix int_edge N_i N_j r dr has the same kind of exact closed form as the volume mass +# matrix M2 (Eq. above): r is linear along the edge, so N_i*N_j*r is a cubic polynomial +# in the edge's local (1-D barycentric) coordinate, integrated exactly below. + +def _boundary_mass_r_edge(r_a: float, r_b: float) -> np.ndarray: + """Exact 2x2 edge matrix ``int_edge N_i N_j r dr`` for a radial edge from r_a to r_b. + + Derived the same way as ``_mass_r_element`` but for a 1-D edge (2 nodes) instead of + a 2-D triangle (3 nodes): expand r(zeta) = r_a*(1-zeta) + r_b*zeta linearly in the + edge's local coordinate and integrate exactly (integrals of monomials in 1-D + barycentric coordinates have the same style of closed form as the 2-D case). + """ + L = abs(r_b - r_a) + return L * np.array([ + [r_a / 4.0 + r_b / 12.0, (r_a + r_b) / 12.0], + [(r_a + r_b) / 12.0, r_a / 12.0 + r_b / 4.0], + ]) + + +def assemble_boundary_admittance(mesh: MeridionalMesh, boundary_nodes: np.ndarray, + coefficient: float) -> sparse.csr_matrix: + """Assemble ``C`` for a compact (frequency-independent) admittance boundary patch. + + ``boundary_nodes`` must be the node indices along the boundary, SORTED BY RADIUS + (exactly what ``MeridionalMesh.nodes_at_x`` returns) — consecutive pairs are treated + as the mesh's boundary edges there. ``coefficient`` is ``c_bar * y`` (real, for the + compact admittance case); the caller multiplies the returned matrix into ``lambda*C`` + when assembling the full operator. + """ + n = mesh.n_nodes + rows, cols, vals = [], [], [] + r_vals = mesh.nodes[boundary_nodes, 1] + for e in range(len(boundary_nodes) - 1): + i, j = boundary_nodes[e], boundary_nodes[e + 1] + Ce = coefficient * _boundary_mass_r_edge(r_vals[e], r_vals[e + 1]) + for a, ia in enumerate((i, j)): + for b, ib in enumerate((i, j)): + rows.append(ia); cols.append(ib); vals.append(Ce[a, b]) + return sparse.coo_matrix((vals, (rows, cols)), shape=(n, n)).tocsr() + + +# --------------------------------------------------------------------------- +# Compact-flame vectors b_k, g_k — new for verification case V3 +# --------------------------------------------------------------------------- +# +# Eq. 11's flame matrix is F(lambda) = (gamma-1) * sum_k exp(-lambda*tau_k) * g_k @ b_k.T +# with two DIFFERENT roles, not one vector reused twice: +# +# b_k = [N_i(x_ref,k)]_i a plain POINT SAMPLE (Eq. 7a: the flame reads the pressure +# at one reference point x_ref, dimensionless interpolation, +# no r-weighting). +# g_k = [int_Omega n_p (qbar_dot/pbar) N_i r dOmega]_i a VOLUME-INTEGRATED weight +# (how much of the domain's test function each node's +# energy-injection couples to; carries the same r dOmega +# measure as every other matrix here). +# +# Treating both as the same plain point sample (an easy mistake — an earlier attempt at +# this module did exactly that) silently makes the flame's effect on the discrete system +# depend on the chamber radius R in an unphysical way: g_k, unlike b_k, needs the r +# dOmega measure to have consistent physical scale against K, M2, and C, all of which +# carry the same measure. Concretely: a point-sampled g_k does not shrink as R shrinks, +# but M2 and K do (both are r-weighted volume integrals) — so on a smaller-radius mesh +# the flame term becomes artificially, arbitrarily stronger relative to everything else. +# Verified numerically before trusting this: with a point-sampled g_k, even a nominally +# "very weak" flame_gain moved the mode's frequency by hundreds of Hz, an obviously +# unphysical sensitivity for a small parameter, and the shift did not scale down when +# flame_gain was reduced further -- the tell that the *coupling itself*, not just its +# value, was mis-scaled. Fixed below by giving g_k its own, properly r-weighted builder. + +def point_sampling_vector(mesh: MeridionalMesh, x0: float, r0: float) -> np.ndarray: + """The b_k vector: nodal weights such that ``g @ p`` = the P1-interpolated value of + field ``p`` at the point ``(x0, r0)`` — a plain point sample, Eq. 7a's x_ref. + + Nonzero only at the 3 nodes of the triangle containing the point (its barycentric + weights there — see ``MeridionalMesh.locate_point``). + """ + tri_index, weights = mesh.locate_point(x0, r0) + b = np.zeros(mesh.n_nodes) + b[mesh.triangles[tri_index]] = weights + return b + + +def disk_load_vector(mesh: MeridionalMesh, column_nodes: np.ndarray) -> np.ndarray: + """The g_k vector for a compact-in-x, UNIFORM-across-the-cross-section flame at the + axial column given by ``column_nodes`` (node indices there, SORTED BY RADIUS — + exactly what ``MeridionalMesh.nodes_at_x`` returns; the flame location must + therefore fall on an exact mesh column, e.g. by building the mesh with + ``two_zone_duct_mesh`` even when there is no real material jump, purely to get an + exact shared column at the flame's axial location). + + ``g_i = int_r N_i(x_f, r) * r dr`` along that column: same r dOmega measure as K, + M2, C (see the module-level note above for why that consistency matters), computed + per edge via the same barycentric-monomial exact-integral trick as + ``_boundary_mass_r_edge``, but for a LOAD VECTOR (int N_i * 1 * r dr) rather than a + mass MATRIX (int N_i N_j r dr): for an edge from r_a to r_b, the exact two-node + contribution is ``L*[r_a/3+r_b/6, r_a/6+r_b/3]`` (L = r_b - r_a). + + Sanity check used while developing this: summed over the whole column, ``g``'s + total is exactly ``int_0^R r dr = R^2/2`` — reproduced below to machine precision, + confirming the exact-integral bookkeeping is right. + """ + n = mesh.n_nodes + g = np.zeros(n) + r_vals = mesh.nodes[column_nodes, 1] + for e in range(len(column_nodes) - 1): + r_a, r_b = r_vals[e], r_vals[e + 1] + edge_len = r_b - r_a + g[column_nodes[e]] += edge_len * (r_a / 3.0 + r_b / 6.0) + g[column_nodes[e + 1]] += edge_len * (r_a / 6.0 + r_b / 3.0) + return g diff --git a/EngineDesign/engine/stability_hifi/acoustics/contour.py b/EngineDesign/engine/stability_hifi/acoustics/contour.py new file mode 100644 index 000000000..a3dd39323 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/acoustics/contour.py @@ -0,0 +1,247 @@ +"""Real chamber contour -> meridional acoustic mesh (P1, paper Sections III.F and IX). + +Replaces the synthetic rectangles of the P0 verification cases with the true chamber +shape. The wall geometry deliberately MIRRORS the suite's hardware construction +(``engine/core/chamber_geometry.py`` / ``chamber_geometry_solver.py`` — the same code +that drives the DXF export), rather than inventing its own parameterization, so the +acoustic domain is the machined part: + + x = 0 (injector face) + |-- cylindrical section, radius R_c, length L_cyl + |-- straight contraction cone at half-angle theta_c (hardware default 45 deg) + |-- circular entrance arc, radius f*R_t (hardware f = 1.5), tangent to the cone + | and to the throat: center at (x_throat, (1+f)*R_t), swept down to the throat + x = x_end (truncation plane; the throat itself is never in the domain) + +Tangency algebra (matches ``rao()``'s entrance arc and +``contraction_length_horizontal_calc`` exactly): the arc point at angle theta from its +center has slope theta from vertical, so the cone (slope theta_c) meets it smoothly at + + r_tangent = R_t * (1 + f*(1 - cos(theta_c))), + L_cone = (R_c - r_tangent) / tan(theta_c), + arc axial span (tangency -> throat) = f * R_t * sin(theta_c). + +Where does the ACOUSTIC domain end? (paper Section III.E) +--------------------------------------------------------- +Not at the throat: the mean flow is sonic there, and the Helmholtz reduction (paper +Appendix B, assumption i) requires low Mach. Instead the domain is truncated at a plane +in the subsonic chamber, and everything downstream of that plane — convergent remainder, +throat, supersonic bell — is represented by the Marble–Candel compact-nozzle admittance +y = (gamma-1)*Mbar_e/2 (paper Appendix C) applied AT the truncation plane, with Mbar_e +the mean Mach number there. Two defensible conventions, both supported: + + * ``truncate_area_ratio=None`` (default): truncate at the convergence-start plane + (end of the cylinder). This is the classical rocket-stability treatment and the + paper's own words ("applied at the nozzle-entrance plane"): the ENTIRE convergent + section + throat is "the compact nozzle". Mbar_e is then the chamber Mach — low + (M ~ 0.1 for a contraction ratio of 6), where the Helmholtz assumptions are most + comfortable. Price: the convergent section's volume is excluded from the mode + computation, so longitudinal frequencies come out slightly high. + * ``truncate_area_ratio = A_plane/A_t in (1, CR)``: extend the domain down the cone/ + arc to the plane with that area ratio, and evaluate Mbar_e there. Captures the + convergent volume's effect on the modes (more accurate frequencies); price is that + the neglected mean-flow terms, O(M), grow toward the plane. Keep the plane where + M <~ 0.4 (area ratio >~ 1.6) unless deliberately studying the sensitivity. + +Mbar_e comes from the subsonic branch of the isentropic area–Mach relation +(``subsonic_mach_from_area_ratio``); the compact admittance itself is deliberately NOT +computed here — geometry provides the Mach number, ``bcs``/assembly applies +(gamma-1)/2 * M, keeping the physics of the boundary condition in one place. + +Meshing a mapped domain +----------------------- +The wall is now a function r_wall(x) instead of a constant, so the structured grid maps +radially: logical node (i, j) sits at (x_i, r_wall(x_i) * j/(nr-1)). The triangulation +topology (``mesh._grid_triangles``) is IDENTICAL to the P0 rectangles — which is the +point: axis handling (row j=0 is exactly r=0), ``nodes_at_x`` for the admittance +column (x_end is an exact grid column by construction), and all of ``assembly.py`` +work unchanged. The rigid sloped wall costs nothing: homogeneous Neumann is the +natural BC of the weak form — it is imposed by NOT adding a boundary term there. + +The axial grid is built per segment (cylinder / cone / arc), sharing endpoints, so the +segment breaks are exact node columns and no element straddles a slope discontinuity +(same reasoning as ``two_zone_duct_mesh``'s interface column: the pressure is smooth +there, but the wall slope is not, and elements that straddle a corner would smear it). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import numpy as np +from scipy.optimize import brentq + +from engine.stability_hifi.acoustics.mesh import MeridionalMesh, _grid_triangles + + +# --------------------------------------------------------------------------- +# Isentropic area–Mach relation (subsonic branch) +# --------------------------------------------------------------------------- + +def area_ratio_from_mach(M: float, gamma: float) -> float: + """Isentropic A/A* as a function of Mach number (either branch). + + A/A* = (1/M) * [ (2/(gamma+1)) * (1 + (gamma-1)/2 * M^2) ]^((gamma+1)/(2(gamma-1))) + + Standard 1-D compressible flow result (mass conservation + isentropic relations + between a plane at Mach M and the sonic throat). + """ + if M <= 0: + return float("inf") + g = gamma + term = (2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M * M) + return float(term ** ((g + 1.0) / (2.0 * (g - 1.0))) / M) + + +def subsonic_mach_from_area_ratio(area_ratio: float, gamma: float) -> float: + """Invert the area–Mach relation on the SUBSONIC branch (0 < M < 1). + + The relation is not analytically invertible; on (0, 1) it is strictly decreasing + from +inf to 1, so for any area_ratio > 1 there is exactly one subsonic root, + bracketed and found by Brent's method. Used to get the mean Mach at the acoustic + truncation plane for the Marble–Candel admittance. + """ + if area_ratio < 1.0: + raise ValueError(f"area_ratio must be >= 1 (got {area_ratio}); A < A* is unphysical") + if area_ratio == 1.0: + return 1.0 + return float(brentq(lambda M: area_ratio_from_mach(M, gamma) - area_ratio, 1e-8, 1.0 - 1e-12)) + + +# --------------------------------------------------------------------------- +# The contour +# --------------------------------------------------------------------------- + +@dataclass +class ChamberAcousticContour: + """Piecewise wall description of the acoustic domain (module docstring for geometry). + + All x measured from the injector face. ``x_throat`` lies BEYOND ``x_end`` (the + throat is never inside the acoustic domain); it is kept for reference/plotting. + """ + R_c: float # chamber (cylinder) radius [m] + R_t: float # throat radius [m] + theta_c: float # contraction cone half-angle [rad] + arc_factor: float # entrance-arc radius / R_t (hardware: 1.5) + L_cyl: float # cylinder length [m] + x_cone_end: float # cone/arc tangency plane [m] (= L_cyl + L_cone) + x_throat: float # virtual throat plane [m] + x_end: float # acoustic truncation plane [m] + area_ratio_end: float # A(x_end) / A_throat + mach_end: float # subsonic Mach at x_end (for the Marble–Candel BC) + + def r_wall(self, x) -> np.ndarray: + """Wall radius at axial position(s) x — piecewise cylinder / cone / arc.""" + x = np.asarray(x, dtype=float) + r = np.full_like(x, self.R_c) + on_cone = (x > self.L_cyl) & (x <= self.x_cone_end) + r = np.where(on_cone, self.R_c - (x - self.L_cyl) * np.tan(self.theta_c), r) + on_arc = x > self.x_cone_end + rho = self.arc_factor * self.R_t + dx = np.minimum(np.abs(x - self.x_throat), rho) # clamp: past-throat query saturates at R_t + r = np.where(on_arc, (1.0 + self.arc_factor) * self.R_t - np.sqrt(rho * rho - dx * dx), r) + return r if r.ndim else float(r) + + +def build_chamber_contour(*, chamber_diameter: float, A_throat: float, + length_cylindrical: float, gamma: float, + theta_contraction_deg: float = 45.0, + arc_factor: float = 1.5, + truncate_area_ratio: Optional[float] = None + ) -> ChamberAcousticContour: + """Build the acoustic contour from the same inputs the hardware geometry uses + (``ChamberGeometryConfig``: chamber_diameter, A_throat, length_cylindrical; + the 45 deg cone and 1.5*R_t arc are the hardware defaults from + ``chamber_geometry_solver.solved_chamber_plot``). + + ``truncate_area_ratio`` selects the acoustic truncation plane (module docstring): + None = convergence-start plane; else the plane where A(x)/A_t equals it. + """ + R_c = 0.5 * chamber_diameter + R_t = float(np.sqrt(A_throat / np.pi)) + if R_t >= R_c: + raise ValueError(f"throat radius {R_t:.4f} m >= chamber radius {R_c:.4f} m") + theta = np.radians(theta_contraction_deg) + f = arc_factor + + r_tangent = R_t * (1.0 + f * (1.0 - np.cos(theta))) + if r_tangent >= R_c: + raise ValueError("entrance arc alone exceeds the chamber radius; " + "contraction ratio too small for this arc_factor/theta") + L_cone = (R_c - r_tangent) / np.tan(theta) + x_cone_end = length_cylindrical + L_cone + x_throat = x_cone_end + f * R_t * np.sin(theta) + + CR = (R_c / R_t) ** 2 + if truncate_area_ratio is None: + x_end = length_cylindrical + area_ratio_end = CR + else: + if not (1.05 <= truncate_area_ratio < CR): + raise ValueError( + f"truncate_area_ratio must be in [1.05, CR={CR:.2f}) — the throat plane " + f"(ratio 1) is sonic and outside Helmholtz validity") + r_trunc = R_t * np.sqrt(truncate_area_ratio) + if r_trunc >= r_tangent: + # plane lands on the straight cone + x_end = length_cylindrical + (R_c - r_trunc) / np.tan(theta) + else: + # plane lands on the entrance arc: invert r_arc(x) + rho = f * R_t + dx = np.sqrt(rho ** 2 - ((1.0 + f) * R_t - r_trunc) ** 2) + x_end = x_throat - dx + area_ratio_end = truncate_area_ratio + + mach_end = subsonic_mach_from_area_ratio(area_ratio_end, gamma) + return ChamberAcousticContour(R_c=R_c, R_t=R_t, theta_c=theta, arc_factor=f, + L_cyl=length_cylindrical, x_cone_end=x_cone_end, + x_throat=x_throat, x_end=x_end, + area_ratio_end=area_ratio_end, mach_end=mach_end) + + +# --------------------------------------------------------------------------- +# Wall-mapped mesh +# --------------------------------------------------------------------------- + +def _segment_xs(contour: ChamberAcousticContour, n_axial: int) -> np.ndarray: + """Axial node columns: per-segment linspaces sharing endpoints, counts allocated + proportionally to segment length (minimum 2 columns per nonempty segment), so the + cylinder/cone and cone/arc breaks — and x_end itself — are exact node columns. + """ + breaks = [0.0, min(contour.L_cyl, contour.x_end)] + if contour.x_end > contour.L_cyl: + breaks.append(min(contour.x_cone_end, contour.x_end)) + if contour.x_end > contour.x_cone_end: + breaks.append(contour.x_end) + breaks = np.array(sorted(set(breaks))) + + lengths = np.diff(breaks) + total = lengths.sum() + xs_parts = [] + for k, (a, b) in enumerate(zip(breaks[:-1], breaks[1:])): + n_seg = max(2, int(round(n_axial * (b - a) / total)) + 1) + seg = np.linspace(a, b, n_seg) + xs_parts.append(seg if k == 0 else seg[1:]) # drop duplicated shared endpoint + return np.concatenate(xs_parts) + + +def contour_mesh(contour: ChamberAcousticContour, n_axial: int, nr: int) -> MeridionalMesh: + """Wall-mapped structured mesh of the acoustic domain. + + Logical node (i, j) -> (x_i, r_wall(x_i) * j/(nr-1)): row j=0 is exactly the axis, + row j=nr-1 exactly the wall, and every column is a constant-x line (so + ``nodes_at_x(contour.x_end)`` finds the admittance-plane column exactly, as the + boundary assembly requires). Topology shared with the P0 meshes via + ``_grid_triangles`` — nothing downstream (assembly, axis conditions, solvers) + changes for a mapped domain. + """ + if nr < 2 or n_axial < 4: + raise ValueError("need nr >= 2 and n_axial >= 4") + xs = _segment_xs(contour, n_axial) + r_wall = contour.r_wall(xs) # (nx,) + eta = np.linspace(0.0, 1.0, nr) # radial mapping coordinate + X = np.repeat(xs, nr) + Rr = np.outer(r_wall, eta).ravel() + nodes = np.column_stack([X, Rr]) + return MeridionalMesh(nodes=nodes, triangles=_grid_triangles(len(xs), nr)) diff --git a/EngineDesign/engine/stability_hifi/acoustics/mesh.py b/EngineDesign/engine/stability_hifi/acoustics/mesh.py new file mode 100644 index 000000000..57ec00ea2 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/acoustics/mesh.py @@ -0,0 +1,179 @@ +"""Structured triangular meridional mesh (paper Section III.F). + +A "meridional mesh" is a 2-D triangulation of the half-plane Omega = {(x, r): r >= 0} +obtained by revolving the chamber contour about the axis of symmetry. Every axisymmetric +mode family (m=0 longitudinal/radial, m=1 first tangential, ...) is solved on the *same* +2-D mesh — only the azimuthal wavenumber ``m`` changes between solves (Eq. 9). + +``cylinder_mesh`` builds the mesh for the uniform right-circular cylinder used by +verification case V1. Later tiers replace it with a mesh built from the true chamber +contour (revolved via gmsh), without touching the assembly or eigensolver code. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass +class MeridionalMesh: + """P1 (linear) triangular mesh of the meridional half-plane. + + ``nodes[k] = (x_k, r_k)`` and ``triangles[e] = (i, j, k)`` are node indices with + counter-clockwise winding in the (x, r) plane. + """ + + nodes: np.ndarray # (n_nodes, 2) + triangles: np.ndarray # (n_tri, 3), int64 + + @property + def n_nodes(self) -> int: + return int(self.nodes.shape[0]) + + @property + def n_tri(self) -> int: + return int(self.triangles.shape[0]) + + def axis_nodes(self, tol: float = 1e-9) -> np.ndarray: + """Node indices on the r=0 axis. + + Needed for the axis-regularity condition (Section III.E): p=0 there for m>=1 + (essential Dirichlet BC), nothing imposed for m=0 (the ``1/r`` weak-form measure + already enforces the natural condition automatically). + """ + return np.nonzero(self.nodes[:, 1] <= tol)[0] + + def nodes_at_x(self, x_value: float, tol: float = 1e-9) -> np.ndarray: + """Node indices lying on the vertical (constant-x) line ``x = x_value``, sorted + by radius. Used to find the boundary column for a Robin/admittance condition + (verification case V3's choked-nozzle end) — any structured mesh built by + ``_structured_mesh`` has an exact shared column there, so this is exact, not a + nearest-node approximation, as long as ``x_value`` matches a column that was + actually built into the grid (e.g. ``L`` for ``cylinder_mesh(L, ...)``). + """ + idx = np.nonzero(np.abs(self.nodes[:, 0] - x_value) <= tol)[0] + return idx[np.argsort(self.nodes[idx, 1])] + + def locate_point(self, x0: float, r0: float, tol: float = 1e-9 + ) -> tuple[int, np.ndarray]: + """Find the element containing ``(x0, r0)`` and its P1 barycentric weights there. + + Needed to place a *compact* (point) source — like V3's point flame — at an + arbitrary location that generally does not sit exactly on a mesh node: the + weak-form contribution of a point source is the test function evaluated there, + which for a P1 field is exactly the barycentric-coordinate interpolation + returned here (see ``assembly.point_sampling_vector``). + + A plain linear scan over triangles, computing barycentric coordinates and + checking they are all in [0, 1] — simple and unambiguous, and fast enough for + the verification-case mesh sizes this is used on (a handful of thousand + triangles, done once per case setup, not per eigen-solve iteration). + + Returns ``(triangle_index, weights)`` with ``weights`` the 3 barycentric + coordinates (summing to 1) at the 3 nodes of that triangle. + """ + for e, tri in enumerate(self.triangles): + v = self.nodes[tri] + x1, r1 = v[0]; x2, r2 = v[1]; x3, r3 = v[2] + denom = (r2 - r3) * (x1 - x3) + (x3 - x2) * (r1 - r3) + if abs(denom) < 1e-30: + continue # degenerate triangle, skip + w1 = ((r2 - r3) * (x0 - x3) + (x3 - x2) * (r0 - r3)) / denom + w2 = ((r3 - r1) * (x0 - x3) + (x1 - x3) * (r0 - r3)) / denom + w3 = 1.0 - w1 - w2 + if (w1 >= -tol) and (w2 >= -tol) and (w3 >= -tol): + return e, np.array([w1, w2, w3]) + raise ValueError(f"point ({x0}, {r0}) not found inside any mesh triangle") + + +def _grid_triangles(nx: int, nr: int) -> np.ndarray: + """Triangulation topology of an nx-by-nr logical node grid (node id = i*nr + j): + each logical quad cell split into 2 triangles along the same diagonal. + + Shared by the plain tensor-product meshes below AND the wall-mapped meshes of + ``contour.py`` (where r depends on x): the topology is identical, only the node + coordinates differ — so the triangulation convention lives in exactly one place. + """ + def nid(i: int, j: int) -> int: + return i * nr + j + + tris = [] + for i in range(nx - 1): + for j in range(nr - 1): + n00, n10 = nid(i, j), nid(i + 1, j) + n01, n11 = nid(i, j + 1), nid(i + 1, j + 1) + # diagonal n00-n11; both triangles wound consistently in (x, r) + tris.append((n00, n10, n11)) + tris.append((n00, n11, n01)) + return np.asarray(tris, dtype=np.int64) + + +def _structured_mesh(xs: np.ndarray, rs: np.ndarray) -> MeridionalMesh: + """Shared builder: structured triangulation over an arbitrary (not necessarily + uniform) axial node grid ``xs`` crossed with a radial node grid ``rs``. + + Both ``cylinder_mesh`` (uniform ``xs`` spacing) and ``two_zone_duct_mesh`` (two + uniformly-spaced runs of ``xs`` stitched together, with a shared column at the + zone interface) delegate to this. + """ + nx, nr = len(xs), len(rs) + X, Rr = np.meshgrid(xs, rs, indexing="ij") # shape (nx, nr); node id = i*nr + j + nodes = np.column_stack([X.ravel(), Rr.ravel()]) + return MeridionalMesh(nodes=nodes, triangles=_grid_triangles(nx, nr)) + + +def cylinder_mesh(L: float, R: float, nx: int, nr: int) -> MeridionalMesh: + """Structured mesh of the rectangle [0, L] x [0, R] (uniform cylinder, case V1). + + ``nx``, ``nr`` are node counts (not cell counts) along the axial and radial + directions. Each of the ``(nx-1)*(nr-1)`` rectangular cells is split into 2 + triangles sharing the same diagonal, so refinement is simply increasing nx, nr. + """ + if nx < 2 or nr < 2: + raise ValueError("cylinder_mesh needs at least 2 nodes per direction") + if L <= 0 or R <= 0: + raise ValueError("L and R must be positive") + + return _structured_mesh(np.linspace(0.0, L, nx), np.linspace(0.0, R, nr)) + + +def two_zone_duct_mesh(L1: float, L2: float, R: float, nx1: int, nx2: int, nr: int + ) -> tuple[MeridionalMesh, np.ndarray]: + """Structured mesh of a duct [0, L1+L2] x [0, R] with an exact node column at the + zone interface ``x = L1`` (verification case V2, temperature-jump duct). + + Why an exact shared column, and not two independent grids glued approximately: + the pressure field itself is physically continuous across a sound-speed + discontinuity (only its second derivative jumps — see the derivation in + ``validation/v2_temperature_jump.py``), so the standard, exact way to represent + that in FEM is a single shared node at the interface, with each side's *element* + (not node) carrying its own material property. Building ``xs`` as two runs of + ``linspace`` that share their common endpoint (rather than gluing two separately- + spaced grids) guarantees that shared column exists exactly, with no search or + tolerance-based node-snapping needed. + + Returns ``(mesh, zone_of_element)`` where ``zone_of_element`` is an ``(n_tri,)`` + int array, 1 for elements entirely in ``[0, L1]`` and 2 for elements entirely in + ``[L1, L1+L2]`` — unambiguous because no element straddles the shared column by + construction. + """ + if nx1 < 2 or nx2 < 2 or nr < 2: + raise ValueError("two_zone_duct_mesh needs at least 2 nodes per direction/zone") + if L1 <= 0 or L2 <= 0 or R <= 0: + raise ValueError("L1, L2, and R must be positive") + + xs1 = np.linspace(0.0, L1, nx1) # nx1 columns, last one at x=L1 + xs2 = np.linspace(L1, L1 + L2, nx2) # nx2 columns, first one at x=L1 (shared) + xs = np.concatenate([xs1, xs2[1:]]) # drop the duplicate shared column + rs = np.linspace(0.0, R, nr) + mesh = _structured_mesh(xs, rs) + + interface_col = nx1 - 1 # 0-indexed column at x=L1 + n_r_cells = nr - 1 + zone_of_element = [] + for i in range(len(xs) - 1): + zone = 1 if (i < interface_col) else 2 + zone_of_element += [zone, zone] * n_r_cells # 2 triangles per (i, j) cell + return mesh, np.asarray(zone_of_element, dtype=np.int64) diff --git a/EngineDesign/engine/stability_hifi/campaign/__init__.py b/EngineDesign/engine/stability_hifi/campaign/__init__.py new file mode 100644 index 000000000..363d959c5 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/campaign/__init__.py @@ -0,0 +1 @@ +"""Stability-map campaign orchestration (paper Section VI). Not yet implemented (post-P0).""" diff --git a/EngineDesign/engine/stability_hifi/eigen/__init__.py b/EngineDesign/engine/stability_hifi/eigen/__init__.py new file mode 100644 index 000000000..d239753dc --- /dev/null +++ b/EngineDesign/engine/stability_hifi/eigen/__init__.py @@ -0,0 +1 @@ +"""Eigenvalue solvers for the (nonlinear) thermoacoustic eigenproblem (paper Section IV).""" diff --git a/EngineDesign/engine/stability_hifi/eigen/nlevp.py b/EngineDesign/engine/stability_hifi/eigen/nlevp.py new file mode 100644 index 000000000..2d8affa62 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/eigen/nlevp.py @@ -0,0 +1,162 @@ +"""Solvers for the active-flame nonlinear eigenvalue problem (NLEVP), verification +case V3. This is the first place ``lambda`` enters nonlinearly (through the flame +delay ``exp(-lambda*tau)``, Eq. 7/11), so it is the first place the paper's Section IV +solver hierarchy is actually needed rather than the plain linear GEVP of ``passive.py``. + +The discrete operator being solved (Eq. 11, specialized to this package's cases: a +single azimuthal wavenumber m=0, a constant boundary-admittance matrix C from a compact +nozzle, and a single compact flame reference, so F(lambda) is rank 1): + + N(lambda) p = [ K + lambda*C + lambda^2*M2 - lambda*flame_gain*exp(-lambda*tau)*g@b.T ] p = 0 + +``K``, ``C``, ``M2`` come from ``acoustics/assembly.py``. ``g`` and ``b`` are TWO +DIFFERENT vectors, not the same one reused (see the long comment in +``acoustics/assembly.py`` above ``disk_load_vector`` for why conflating them is a real, +previously-caught bug): ``b`` (``assembly.point_sampling_vector``) is a plain point +sample of the pressure at the flame's reference location (Eq. 7a); ``g`` +(``assembly.disk_load_vector``) is the r-weighted energy-injection weight, carrying the +same ``r dOmega`` measure as every other matrix here. ``flame_gain`` and ``tau`` are the +lumped Crocco interaction strength and time lag (see ``validation/v3_ntau_duct.py`` for +how they relate to the physical n, tau of Eq. 2.1 and to this module's own dispersion +relation). + +Scale note: everything here is DENSE (``numpy``/``scipy.linalg``, not sparse/Krylov). +That is a deliberate scope choice for P0's verification meshes (a few hundred nodes): +the sparse/shift-invert machinery of Section IV.E is already exercised (for the +LINEAR-in-lambda case) by ``passive.py``; this module's job is to validate the +NONLINEAR delay handling, a separate concern, at a problem size where a dense solve is +simpler to write and just as fast. A production-scale (P1+) NLEVP solver would still +need the sparse Krylov kernel underneath the same two algorithms below. +""" + +from __future__ import annotations + +from typing import Tuple + +import numpy as np +from scipy import linalg as sla + + +def _frozen_A1(C: np.ndarray, flame_gain: float, tau: float, + g: np.ndarray, b: np.ndarray, lam: complex) -> np.ndarray: + """The coefficient of ``lambda`` in N(lambda), with the flame's delay factor + ``exp(-lambda*tau)`` FROZEN at the current iterate ``lam`` (Algorithm 1, step 3). + + N(lambda) = K + lambda*[C - flame_gain*exp(-lam*tau)*g@b.T] + lambda^2*M2 once + frozen — a genuine QUADRATIC eigenvalue problem in lambda, since freezing the delay + factor at a fixed number removes the only source of nonlinearity. + """ + return C - flame_gain * np.exp(-lam * tau) * np.outer(g, b) + + +def _companion_solve(A0: np.ndarray, A1: np.ndarray, A2: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """All eigenpairs of the quadratic pencil ``A0 + lambda*A1 + lambda^2*A2 = 0`` via + the standard companion linearization to a size-2N generalized eigenvalue problem + (Algorithm 1, step 5 — "companion-linearize to GEVP of size 2N_h"). + + Writing ``z = [p; lambda*p]``, one checks by direct substitution that + + [[0, I], [-A0, -A1]] z = lambda * [[I, 0], [0, A2]] z + + reproduces exactly ``A0 p + lambda A1 p + lambda^2 A2 p = 0`` (the top block row + gives the trivial identity ``lambda*p = lambda*p``; the bottom block row gives the + QEP once that identity is substituted in). Solved directly and densely + (``scipy.linalg.eig``) since P0's verification meshes are small — see module + docstring for why that is an adequate, deliberate scope choice here. + """ + n = A0.shape[0] + Z = np.zeros((n, n), dtype=complex) + I = np.eye(n, dtype=complex) + L0 = np.block([[Z, I], [-A0, -A1]]) + L1 = np.block([[I, Z], [Z, A2]]) + eigvals, eigvecs = sla.eig(L0, L1) + p_vecs = eigvecs[:n, :] # the physical eigenvector is the top half of z + return eigvals, p_vecs + + +def fixed_point_solve(K: np.ndarray, C: np.ndarray, M2: np.ndarray, flame_gain: float, + tau: float, g: np.ndarray, b: np.ndarray, lambda_seed: complex, + *, tol: float = 1e-9, max_iter: int = 50 + ) -> Tuple[complex, np.ndarray, int]: + """Algorithm 1 (Section IV.B): frozen-delay fixed point, specialized to this + package's single-flame-reference, dense-small-scale case. + + At each outer iterate, freeze ``exp(-lambda*tau)`` at the current guess, solve the + resulting *linear* (in the sense of ordinary QEP, not NLEVP) quadratic eigenvalue + problem exactly via companion linearization, and take whichever of its 2N + eigenvalues is nearest the current guess as the next iterate. Converges linearly; + the paper reports 3-8 outer iterations for realistic parameters, matched here. + + Returns ``(lambda, p, n_iterations)``. + """ + lam = complex(lambda_seed) + p = None + for it in range(max_iter): + A1 = _frozen_A1(C, flame_gain, tau, g, b, lam) + eigvals, eigvecs = _companion_solve(K.astype(complex), A1, M2.astype(complex)) + idx = int(np.argmin(np.abs(eigvals - lam))) + lam_new = eigvals[idx] + p = eigvecs[:, idx] + if abs(lam_new - lam) < tol * max(abs(lam_new), 1.0): + return lam_new, p, it + 1 + lam = lam_new + return lam, p, max_iter + + +def residual_N(K: np.ndarray, C: np.ndarray, M2: np.ndarray, flame_gain: float, tau: float, + g: np.ndarray, b: np.ndarray, lam: complex, p: np.ndarray) -> np.ndarray: + """N(lambda) @ p — the raw NLEVP residual, used to check solver agreement/quality.""" + N = K + lam * C + lam ** 2 * M2 - lam * flame_gain * np.exp(-lam * tau) * np.outer(g, b) + return N @ p + + +def newton_polish(K: np.ndarray, C: np.ndarray, M2: np.ndarray, flame_gain: float, tau: float, + g: np.ndarray, b: np.ndarray, lambda0: complex, p0: np.ndarray, + *, tol: float = 1e-12, max_iter: int = 30 + ) -> Tuple[complex, np.ndarray, int]: + """Bordered Newton polish (Section IV.C), solving the *true* NLEVP (delay left in, + not frozen) to quadratic convergence from the fixed-point solver's output. + + The bordered system (paper's own equations, Section IV.C): + + [[N(lambda), N'(lambda) p], [dp ] [ N(lambda) p ] + [c^H, 0 ]] @ [dlambda] = - [ c^H p - 1 ] + + with the normalization row ``c^H p = 1`` fixing scale/phase (paper uses a generic + ``c``; here ``c = e_k``, a unit vector at whichever component of ``p`` has the + largest magnitude — a simple, standard pivoting choice so the constraint row is + never close to singular). + + N'(lambda) = C + 2*lambda*M2 - flame_gain*exp(-lambda*tau)*(1 - lambda*tau)*outer(g,b) + (derivative of the "-lambda*flame_gain*exp(-lambda*tau)" term in N(lambda) w.r.t. + lambda; C and M2 contribute their usual constant/2*lambda derivatives). + """ + lam = complex(lambda0) + p = p0.astype(complex).copy() + n = len(p) + k = int(np.argmax(np.abs(p))) + p = p / p[k] # normalize so the pivot component is exactly 1, matching c^H p = 1 + + for it in range(max_iter): + N = K + lam * C + lam ** 2 * M2 - lam * flame_gain * np.exp(-lam * tau) * np.outer(g, b) + Nprime = (C + 2.0 * lam * M2 + - flame_gain * np.exp(-lam * tau) * (1.0 - lam * tau) * np.outer(g, b)) + + bordered = np.zeros((n + 1, n + 1), dtype=complex) + bordered[:n, :n] = N + bordered[:n, n] = Nprime @ p + bordered[n, k] = 1.0 # c^H = e_k^T + + rhs = np.zeros(n + 1, dtype=complex) + rhs[:n] = -(N @ p) + rhs[n] = -(p[k] - 1.0) + + delta = np.linalg.solve(bordered, rhs) + dp, dlam = delta[:n], delta[n] + p = p + dp + lam = lam + dlam + + if abs(dlam) < tol * max(abs(lam), 1.0) and np.linalg.norm(dp) < tol * max(np.linalg.norm(p), 1.0): + return lam, p, it + 1 + + return lam, p, max_iter diff --git a/EngineDesign/engine/stability_hifi/eigen/passive.py b/EngineDesign/engine/stability_hifi/eigen/passive.py new file mode 100644 index 000000000..e57fe3809 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/eigen/passive.py @@ -0,0 +1,80 @@ +"""Passive (flame-off, rigid-wall) modal solve — the shift-invert Krylov kernel of +Section IV.E, specialized to the *linear* case that appears with no flame and no +boundary impedance. + +With ``C = 0`` (rigid walls only) and ``F = 0`` (no active flame), the discrete NLEVP +(Eq. 11) collapses to + + (K + m^2 Km) p = -lambda^2 M2 p . + +Writing ``mu = -lambda^2``, this is an ordinary real-symmetric *generalized* eigenvalue +problem ``A p = mu M2 p`` with ``A = K + m^2 Km``. Both ``A`` and ``M2`` are symmetric +positive semi-definite (a lossless rigid-wall cavity cannot amplify or dissipate), so +``mu >= 0`` and every eigenvalue is purely oscillatory: ``lambda = +/- i*sqrt(mu)``, +``omega = sqrt(mu)``, ``f = omega / (2 pi)``. This is exactly the passive-mode seeding +step of Algorithm 2 (paper Section IV.F, step 5) and case V1 of the verification ladder. + +We solve it with shift-invert Lanczos (``scipy.sparse.linalg.eigsh``, which is symmetric +Arnoldi/Lanczos — the real-symmetric specialization of the Krylov-Schur kernel described +in Section IV.E): factor ``(A - sigma*M2)`` once and iterate on its action, which makes +the eigenvalues nearest the shift ``sigma`` the *extremal* (best-converging) ones of the +transformed operator, exactly the mechanism Section IV.E describes for the general +(non-symmetric, shift-invert Krylov-Schur) case. +""" + +from __future__ import annotations + +from typing import Tuple + +import numpy as np +from scipy import sparse +from scipy.sparse.linalg import eigsh + +from engine.stability_hifi.acoustics.assembly import assemble_passive +from engine.stability_hifi.acoustics.mesh import MeridionalMesh + + +def solve_passive_modes(mesh: MeridionalMesh, c_sound: np.ndarray, m: int, + *, n_modes: int, sigma: float) -> Tuple[np.ndarray, np.ndarray]: + """Lowest ``n_modes`` passive acoustic frequencies and mode shapes at wavenumber ``m``. + + ``sigma`` is the shift [rad/s]^2 (i.e. an ``omega^2`` guess) placed near the band of + interest — shift placement is not guesswork (Section IV.E): pass ``(2*pi*f_guess)**2`` + for whatever frequency band you expect from the analytic estimate. + + Axis regularity (Section III.E) is imposed here, not in ``assemble_passive``: for + ``m == 0`` nothing is done (natural condition, automatic from the ``r``-weighted weak + form); for ``m >= 1`` the r=0 axis nodes are eliminated (essential p=0 Dirichlet + condition) before the solve and their (zero) values are scattered back afterward. + + Returns ``(freqs_hz, mode_shapes)`` with ``mode_shapes`` shape ``(n_nodes, n_modes)``, + sorted by ascending frequency. + """ + K, Km, M2 = assemble_passive(mesh, c_sound, m) + A = (K + (m * m) * Km).tocsc() + M = M2.tocsc() + + if m == 0: + free = np.arange(mesh.n_nodes) + else: + axis = mesh.axis_nodes() + keep = np.ones(mesh.n_nodes, dtype=bool) + keep[axis] = False + free = np.nonzero(keep)[0] + A = A[free][:, free] + M = M[free][:, free] + + k = min(n_modes, A.shape[0] - 2) + if k < 1: + raise ValueError("mesh too coarse for the requested number of modes") + + vals, vecs = eigsh(A, k=k, M=M, sigma=sigma, which="LM") + order = np.argsort(vals) + vals = np.clip(vals[order], 0.0, None) # numerical noise can make near-zero mu slightly negative + vecs = vecs[:, order] + + freqs_hz = np.sqrt(vals) / (2.0 * np.pi) + + full_vecs = np.zeros((mesh.n_nodes, vecs.shape[1])) + full_vecs[free, :] = vecs + return freqs_hz, full_vecs diff --git a/EngineDesign/engine/stability_hifi/meanflow/__init__.py b/EngineDesign/engine/stability_hifi/meanflow/__init__.py new file mode 100644 index 000000000..07467fc72 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/meanflow/__init__.py @@ -0,0 +1 @@ +"""Mean-flow generation and the MeanFlowSpec interface (paper Section V).""" diff --git a/EngineDesign/engine/stability_hifi/meanflow/spec.py b/EngineDesign/engine/stability_hifi/meanflow/spec.py new file mode 100644 index 000000000..639f6fafc --- /dev/null +++ b/EngineDesign/engine/stability_hifi/meanflow/spec.py @@ -0,0 +1,124 @@ +"""The ``MeanFlowSpec`` interface (paper Section V.A). + +Every eigensolver input passes through this one container, so mean-flow fidelity +(synthetic uniform field -> parametric generator -> warm-started SU2 RANS) can be +swapped without touching the mesh, assembly, or eigensolver code. P0 only uses the +synthetic generator at the bottom of this file. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +import numpy as np + +from engine.stability_hifi.acoustics.mesh import MeridionalMesh, cylinder_mesh, two_zone_duct_mesh + + +@dataclass +class FlameReference: + """One flame reference point/ring for the flame-response closure (Eq. 7). + + Not used by the passive verification cases (V1-V2); populated once an active + flame closure is added (V3 onward). + """ + x_ref: float + r_ref: float + n_gain: float # interaction index (n_p or n_u) + tau: float # time lag [s] + normal: tuple = (1.0, 0.0) # reference-plane normal, for velocity coupling (Eq. 7b) + + +@dataclass +class ProvenanceRecord: + """Traceability metadata: every eigenvalue should be traceable to its mean flow.""" + generator: str + propellants: Optional[str] = None + Pc: Optional[float] = None + MR: Optional[float] = None + notes: str = "" + + +@dataclass +class MeanFlowSpec: + """Scalar (Helmholtz-tier) mean-flow fields on a meridional mesh (Eq. 9-10). + + ``c`` is the paper's per-node sound-speed field (Section V.A) — the right + representation for any *smoothly varying* mean flow, which is what every real + generator (parametric or CFD) produces. ``c_element``, an EngineDesign-specific + addition not in the paper, is an escape hatch for synthetic verification cases that + need a genuinely *discontinuous* material property a shared node cannot represent + (verification case V2's temperature-jump duct is the only user of it in P0). When + present, ``assemble_passive`` should be called with ``c_element`` instead of ``c``; + ``c`` is still filled in (as a defensible smooth proxy) purely so the dataclass + contract — "every field the paper lists is always populated" — holds even here. + """ + mesh: MeridionalMesh + rho: np.ndarray # (n_nodes,) mean density [kg/m^3] + c: np.ndarray # (n_nodes,) sound speed [m/s] + gamma: np.ndarray # (n_nodes,) specific heat ratio [-] + qbar: np.ndarray # (n_nodes,) mean volumetric heat release [W/m^3] + ubar: Optional[np.ndarray] = None # (n_nodes, 2), None at Helmholtz tier + refs: List[FlameReference] = field(default_factory=list) + meta: Optional[ProvenanceRecord] = None + c_element: Optional[np.ndarray] = None # (n_tri,), set only for discontinuous test cases (see above) + + +def synthetic_uniform_cylinder(L: float, R: float, nx: int, nr: int, + *, c_sound: float, gamma: float = 1.2, + rho: float = 4.0) -> MeanFlowSpec: + """Uniform-property closed cylinder mean flow (verification case V1). + + Constant sound speed / density / gamma everywhere, no heat release (passive) — the + exact configuration the analytic mode formula in Section VII.A (case V1) assumes. + """ + mesh = cylinder_mesh(L, R, nx, nr) + n = mesh.n_nodes + return MeanFlowSpec( + mesh=mesh, + rho=np.full(n, rho), + c=np.full(n, c_sound), + gamma=np.full(n, gamma), + qbar=np.zeros(n), + ubar=None, + refs=[], + meta=ProvenanceRecord(generator="synthetic_uniform_cylinder", + notes="V1 analytic-cylinder verification case; no real propellant/Pc/MR"), + ) + + +def synthetic_two_zone_duct(L1: float, L2: float, R: float, nx1: int, nx2: int, nr: int, + *, c1: float, c2: float, gamma: float = 1.2, + rho: float = 4.0) -> MeanFlowSpec: + """Two-zone duct with a sound-speed (temperature) jump at x=L1 (verification case V2). + + ``c1`` applies on ``[0, L1]``, ``c2`` on ``[L1, L1+L2]`` — a step discontinuity, not + a smooth profile, which is exactly why this uses ``c_element`` (see ``MeanFlowSpec`` + docstring) rather than the ordinary per-node ``c`` field. ``rho`` is passed through + uniformly for now; density does jump physically alongside sound speed at a real + temperature interface (rho = p/(R*T), same p, different T), but the passive + Helmholtz operator (Eq. 10) only ever uses ``c``, so it plays no role in this case. + """ + mesh, zone = two_zone_duct_mesh(L1, L2, R, nx1, nx2, nr) + n = mesh.n_nodes + c_element = np.where(zone == 1, c1, c2) + # Per-node c is not physically meaningful across the jump (see docstring); fill it + # with each node's "home" zone value (nodes exactly on the interface get c1) purely + # so the dataclass field is populated with *something* traceable, never used by the solve. + node_zone = np.ones(n, dtype=np.int64) + right_of_interface = mesh.nodes[:, 0] > L1 + 1e-9 + node_zone[right_of_interface] = 2 + c_node_proxy = np.where(node_zone == 1, c1, c2) + return MeanFlowSpec( + mesh=mesh, + rho=np.full(n, rho), + c=c_node_proxy, + c_element=c_element, + gamma=np.full(n, gamma), + qbar=np.zeros(n), + ubar=None, + refs=[], + meta=ProvenanceRecord(generator="synthetic_two_zone_duct", + notes=f"V2 temperature-jump verification case; c1={c1}, c2={c2}, interface at x={L1}"), + ) diff --git a/EngineDesign/engine/stability_hifi/validation/__init__.py b/EngineDesign/engine/stability_hifi/validation/__init__.py new file mode 100644 index 000000000..92888d590 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/validation/__init__.py @@ -0,0 +1,2 @@ +"""Verification ladder (paper Section VII.A, cases V1-V6). Each case is also exercised +by a pytest test in ``tests/`` so it runs as a CI regression test.""" diff --git a/EngineDesign/engine/stability_hifi/validation/contour_checks.py b/EngineDesign/engine/stability_hifi/validation/contour_checks.py new file mode 100644 index 000000000..4a381fef7 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/validation/contour_checks.py @@ -0,0 +1,89 @@ +"""Verification helpers for the P1 contour->mesh path (supplementary to the paper's +V1-V6 ladder; these guard the *geometry* machinery specifically, before the V4 +cross-code benchmark exists). + +Two independent references are built here: + +1. ``webster_modes`` — Webster's horn equation, the 1-D limit of the thermoacoustic + Helmholtz equation (paper Eq. 6, passive, uniform c) in a duct of slowly varying + cross-section A(x). Derivation: assume p_hat uniform over each cross-section + (valid when the wall slope is small and the frequency is below the first + transverse cutoff), integrate Eq. 6 over the cross-section, and use the rigid-wall + condition to drop the wall flux: + + d/dx ( A(x) c^2 dp_hat/dx ) + omega^2 A(x) p_hat = 0, p_hat'(0)=p_hat'(L)=0. + + This is NOT exact for our 2-D domains — its error is O(wall slope^2) — so it is + used as a cross-check on gently tapered contours (10 deg cone: slope^2 ~ 0.03), + where sub-1% agreement is expected, and deliberately NOT on the 45-deg hardware + contour (slope^2 = 1), where self-convergence is the right check instead. + Discretized by 1-D P1 FEM with A(x) linear per element (exact element integrals), + on a grid fine enough (thousands of elements) that its own discretization error is + negligible against the model error being tolerated. + +2. ``revolved_volume`` — the exact volume of the revolved contour by adaptive + quadrature of pi * r_wall(x)^2 dx (piecewise-analytic integrand, split at the + segment breaks). The FEM mesh must reproduce this through the identity + 2*pi*sum_ij(M2_ij) = 2*pi*int r dOmega = Volume (rows of M2 sum shape functions + to 1) — a direct test that the mapped mesh covers exactly the intended solid of + revolution, independent of any eigenvalue. +""" + +from __future__ import annotations + +from typing import List + +import numpy as np +from scipy import linalg as sla +from scipy.integrate import quad + +from engine.stability_hifi.acoustics.contour import ChamberAcousticContour + + +def webster_modes(contour: ChamberAcousticContour, c_sound: float, n_modes: int, + n_elements: int = 4000) -> List[float]: + """Lowest ``n_modes`` longitudinal frequencies [Hz] of the Webster horn equation + on [0, x_end] with rigid ends, A(x) = pi * r_wall(x)^2. + + 1-D P1 FEM: with A linear over each element (endpoint values A1, A2, length h), + + K_e = c^2 * (A1+A2)/2 / h * [[1, -1], [-1, 1]] (A linear, p' const: exact) + M_e = h/12 * [[3*A1 + A2, A1 + A2], [A1 + A2, A1 + 3*A2]] (cubic integrand: exact) + + giving the symmetric GEVP K p = omega^2 M p; rigid ends are natural (no boundary + term). Dense solve — a 1-D problem of a few thousand nodes is trivial. + """ + xs = np.linspace(0.0, contour.x_end, n_elements + 1) + A = np.pi * contour.r_wall(xs) ** 2 + n = len(xs) + K = np.zeros((n, n)) + M = np.zeros((n, n)) + for k in range(n_elements): + h = xs[k + 1] - xs[k] + A1, A2 = A[k], A[k + 1] + Ke = c_sound ** 2 * 0.5 * (A1 + A2) / h * np.array([[1.0, -1.0], [-1.0, 1.0]]) + Me = h / 12.0 * np.array([[3 * A1 + A2, A1 + A2], [A1 + A2, A1 + 3 * A2]]) + K[k:k + 2, k:k + 2] += Ke + M[k:k + 2, k:k + 2] += Me + w2 = sla.eigh(K, M, eigvals_only=True) + w2 = np.clip(w2, 0.0, None) + freqs = np.sqrt(w2) / (2.0 * np.pi) + # The all-Neumann problem has one exact zero eigenvalue (uniform pressure — same + # trivial mode V1 excludes); discard everything far below the 1L scale c/(2L). + freqs = freqs[freqs > 0.05 * c_sound / (2.0 * contour.x_end)] + return [float(f) for f in freqs[:n_modes]] + + +def revolved_volume(contour: ChamberAcousticContour) -> float: + """Exact volume [m^3] of the revolved acoustic domain, by adaptive quadrature of + pi * r_wall^2 dx split at the segment breaks (integrand is analytic per segment). + """ + breaks = sorted({0.0, min(contour.L_cyl, contour.x_end), + min(contour.x_cone_end, contour.x_end), contour.x_end}) + total = 0.0 + for a, b in zip(breaks[:-1], breaks[1:]): + if b <= a: + continue + val, _err = quad(lambda x: np.pi * float(contour.r_wall(x)) ** 2, a, b, limit=200) + total += val + return float(total) diff --git a/EngineDesign/engine/stability_hifi/validation/v1_cylinder.py b/EngineDesign/engine/stability_hifi/validation/v1_cylinder.py new file mode 100644 index 000000000..9c934dd06 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/validation/v1_cylinder.py @@ -0,0 +1,91 @@ +"""Verification case V1: uniform closed-closed cylinder, passive (paper Section VII.A). + +Analytic reference (rigid walls at both ends and the side wall): + + f_{m,n,k} = (c / 2*pi) * sqrt( (alpha'_{m,n} / R_c)^2 + (k*pi / L)^2 ) + +where ``alpha'_{m,n}`` is the n-th positive zero of ``J'_m`` (hard-wall transverse +eigenvalue — the same zeros used by the lumped model's +``engine.pipeline.stability.core.TRANSVERSE_EIGENVALUES``, cross-checked here from +first principles via a 2-D FEM solve rather than assumed) and ``k`` is the number of +axial half-wavelengths (``k=0`` allowed: a pure transverse mode with no axial +variation). ``m=n=k=0`` is excluded: it is the trivial uniform-pressure mode, an exact +zero eigenvalue of an all-Neumann-boundary problem (constant pressure has zero +gradient, hence zero stiffness) — physically real but not an *acoustic* mode. + +This module builds the FEM prediction (via ``eigen.passive.solve_passive_modes`` on a +``synthetic_uniform_cylinder`` mean flow) and reports it against the closed form. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +import numpy as np +from scipy.special import jnp_zeros + +from engine.stability_hifi.eigen.passive import solve_passive_modes +from engine.stability_hifi.meanflow.spec import synthetic_uniform_cylinder + + +@dataclass +class ModeComparison: + m: int + alpha: float # J'_m zero used (0.0 for the uniform-in-r branch, m=0 only) + k: int # axial half-wavelength count + f_analytic_hz: float + f_fem_hz: float + + @property + def rel_error(self) -> float: + return abs(self.f_fem_hz - self.f_analytic_hz) / self.f_analytic_hz + + +def radial_eigenvalues(m: int, n_max: int) -> np.ndarray: + """Positive zeros of J'_m relevant to azimuthal wavenumber ``m``. + + For ``m=0`` the r=0 axis condition is Neumann (natural), so alpha=0 (uniform-in-r) + is an admissible branch, prepended to the true positive zeros of J'_0. For ``m>=1`` + the axis condition is Dirichlet (p=0 at r=0, Section III.E), so alpha=0 is not + admissible and only the true positive zeros of J'_m apply. + """ + zeros = jnp_zeros(m, n_max) if n_max > 0 else np.array([]) + if m == 0: + return np.concatenate([[0.0], zeros]) + return zeros + + +def analytic_frequencies(c: float, L: float, R: float, m: int, + n_radial_max: int = 2, k_max: int = 2) -> List[ModeComparison]: + """All analytic (alpha, k) candidate frequencies for wavenumber ``m``, trivial mode excluded.""" + out = [] + for alpha in radial_eigenvalues(m, n_radial_max): + for k in range(k_max + 1): + if alpha == 0.0 and k == 0: + continue # trivial uniform-pressure mode (mu=0), not acoustic + f = (c / (2.0 * np.pi)) * np.sqrt((alpha / R) ** 2 + (k * np.pi / L) ** 2) + out.append(ModeComparison(m=m, alpha=float(alpha), k=k, f_analytic_hz=float(f), f_fem_hz=float("nan"))) + out.sort(key=lambda mc: mc.f_analytic_hz) + return out + + +def run_case(*, c: float, L: float, R: float, m: int, nx: int, nr: int, + n_radial_max: int = 2, k_max: int = 2, n_compare: int = 4) -> List[ModeComparison]: + """Run the FEM solve for wavenumber ``m`` and match the lowest ``n_compare`` modes + against the analytic candidates (both lists sorted ascending, matched pairwise). + """ + candidates = analytic_frequencies(c, L, R, m, n_radial_max, k_max)[:n_compare] + sigma = (2.0 * np.pi * 0.5 * candidates[0].f_analytic_hz) ** 2 # below the lowest true mode + + spec = synthetic_uniform_cylinder(L, R, nx, nr, c_sound=c) + freqs_hz, _ = solve_passive_modes(spec.mesh, spec.c, m, n_modes=n_compare + 2, sigma=sigma) + + # Drop near-zero (trivial) modes before matching against the (already trivial-excluded) + # analytic candidates; keep the lowest n_compare survivors. + f_min_analytic = candidates[0].f_analytic_hz + freqs_hz = np.sort(freqs_hz[freqs_hz > 0.05 * f_min_analytic])[:n_compare] + + for mc, f_fem in zip(candidates, freqs_hz): + mc.f_fem_hz = float(f_fem) + return candidates diff --git a/EngineDesign/engine/stability_hifi/validation/v2_temperature_jump.py b/EngineDesign/engine/stability_hifi/validation/v2_temperature_jump.py new file mode 100644 index 000000000..7ef3ac01f --- /dev/null +++ b/EngineDesign/engine/stability_hifi/validation/v2_temperature_jump.py @@ -0,0 +1,188 @@ +"""Verification case V2: 1-D duct with a temperature (sound-speed) jump, passive +(paper Section VII.A: "checks: nonuniform-c-bar handling"). + +The paper *names* this case but does not give its analytic reference solution, so the +derivation below is original (not copied from the paper) — done directly from the +governing equation the paper does give, Eq. (6): + + lambda^2 p_hat - d/dx( c_bar^2 dp_hat/dx ) = 0 (1-D, no heat release) + +Setup: a duct of length L = L1 + L2, split at x = L1 into two uniform zones with sound +speeds c1 (zone 1, 0 <= x <= L1) and c2 (zone 2, L1 <= x <= L1+L2). Rigid ("closed") ends +at x=0 and x=L, matching Section III.E's rigid-wall Neumann condition. Only the +sound-speed field is nonuniform; nothing about the boundary condition or the flame +(there is none) is new relative to V1 — this isolates exactly the one new piece of +physics V2 is meant to check. + +Derivation +---------- +Looking for undamped natural frequencies (lambda = i*omega, omega real — same as V1; +a lossless medium can't grow or decay), Eq. (6) in each uniform zone reduces to the +textbook 1-D Helmholtz equation + + d^2 p_hat/dx^2 = -k_i^2 p_hat, k_i = omega / c_i (i = 1, 2) + +with general solution p_hat(x) = A*cos(k_i*x) + B*sin(k_i*x). The rigid-end conditions +p_hat'(0) = 0 and p_hat'(L) = 0 fix the form in each zone up to one free amplitude: + + zone 1: p_hat_1(x) = A1 * cos(k1*x) [only cos satisfies p'(0)=0] + zone 2: p_hat_2(x) = A2 * cos(k2*(x - L)) [satisfies p'(L)=0 by construction] + +At the interface x=L1, we need two matching conditions. Pressure continuity, (a), is +uncontroversial (nothing sources or sinks p there). Condition (b) is the one that is +easy to get wrong, and an earlier draft of this derivation *did* get it wrong (kept +below as a documented correction, since the mistake and the fix are both instructive): + + WRONG first attempt: continuity of mass flux rho_bar*u_hat, on the (mistaken) + reasoning that this is the standard duct-acoustics matching condition when gas + properties change across an interface. Using u_hat = -grad(p_hat)/(lambda*rho_bar) + (given right after Eq. 6), rho1*u_hat_1 = rho2*u_hat_2 reduces (both rho AND lambda + cancel) to dp_hat_1/dx = dp_hat_2/dx, giving k1*tan(k1*L1) + k2*tan(k2*L2) = 0. + + This numerically MISMATCHED the FEM solution by several percent at every mesh + resolution, with no improvement on refinement — a strong signal (per the mesh- + convergence-order test in V1) of a wrong reference formula, not a discretization + error. Tracking it down: the standard "mass-flux continuity" rule from general duct + acoustics assumes a mean flow physically carrying mass across the interface. But + Section III.C's Helmholtz reduction explicitly assumes a *quiescent* mean flow + (u_bar ~ 0) — there is no throughflow here at all, just a spatially-varying sound + speed in still gas. The right way to find the correct condition is to go back one + step further than Eq. 6, to the *pair* of first-order equations it was combined + from (Eq. 4): the energy equation "dp'/dt + gamma*p_bar*div(u') = (gamma-1)*q_dot'" + uses the volumetric dilatation div(u') directly (a purely kinematic quantity, not a + mass flux), and Appendix B's assumption (ii) is that gamma*p_bar is spatially + UNIFORM (mean pressure barely drops across the chamber) even though rho_bar is not. + Integrating that energy equation across a vanishingly thin control volume straddling + the interface forces the u' term's jump to vanish for the equation to stay finite — + i.e. **u_hat itself (not rho_bar*u_hat) must be continuous.** Equivalently, since + c_bar^2 = gamma*p_bar/rho_bar and gamma*p_bar is the same constant on both sides, + continuity of u_hat (using u_hat = -grad(p_hat)/(lambda*rho_bar)) is the same + statement as continuity of c_bar^2 * dp_hat/dx — which is also exactly the *natural* + (weak-form) interface condition that Eq. (10)'s FEM discretization enforces + automatically at any element boundary where c jumps. That the corrected physics + argument and the FEM's own automatic behavior agree is a good consistency check. + +Redoing the algebra with the corrected condition (b): c1^2 * dp_hat_1/dx = c2^2 * +dp_hat_2/dx at x=L1. Substituting the two zone solutions into "p continuous" and +"c^2 p' continuous" and eliminating A1, A2 (using k_i = omega/c_i, so c_i^2 * k_i = +c_i * omega): + + A1 cos(k1*L1) = A2 cos(k2*L2) (p continuous) + -c1*omega*A1 sin(k1*L1) = c2*omega*A2 sin(k2*L2) (c^2 p' continuous) + +gives the dispersion relation: + + c1 * tan(k1*L1) + c2 * tan(k2*L2) = 0. + +Sanity check (uniform limit): if c1 = c2 = c, both this and the wrong first attempt +collapse to the same tan(k*L1) = -tan(k*(L-L1)), i.e. f = n*c/(2L) — V1's closed-closed +spectrum. **This is why the uniform-limit check alone did not catch the error**: any +relation of the schematic form (function of c1, k1*L1) + (function of c2, k2*L2) = 0 +that is antisymmetric the same way trivially passes it regardless of which power of +c_i actually belongs there. The uniform limit is a necessary but not sufficient check; +comparing against the independently-built FEM solution (which does not share whatever +mistake was made in the by-hand derivation) is what actually caught this one. + +Numerically, ``tan`` has poles wherever k_i*L_i passes through (2n+1)*pi/2, which are +not physical roots but *look* like sign changes to a naive root-finder. To avoid that +trap, the code below root-finds the equivalent POLE-FREE form obtained by multiplying +through by cos(k1*L1)*cos(k2*L2): + + g(omega) = c1*sin(k1*L1)*cos(k2*L2) + c2*cos(k1*L1)*sin(k2*L2) = 0 + +which is smooth (entire) in omega, so every sign change of g really is a root. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +import numpy as np +from scipy.optimize import brentq + +from engine.stability_hifi.eigen.passive import solve_passive_modes +from engine.stability_hifi.meanflow.spec import synthetic_two_zone_duct + + +@dataclass +class ModeComparison: + f_analytic_hz: float + f_fem_hz: float + + @property + def rel_error(self) -> float: + return abs(self.f_fem_hz - self.f_analytic_hz) / self.f_analytic_hz + + +def _dispersion_g(f_hz: float, c1: float, L1: float, c2: float, L2: float) -> float: + """Pole-free dispersion residual (see module docstring). Root at f_hz => a true mode. + + Corrected form ``c1*sin(k1L1)cos(k2L2) + c2*cos(k1L1)sin(k2L2)`` (velocity-continuity + interface condition) — NOT ``k1*sin(...)+k2*sin(...)`` (mass-flux continuity), which + was tried first and shown to be wrong; see the module docstring for the full story. + """ + omega = 2.0 * np.pi * f_hz + k1, k2 = omega / c1, omega / c2 + return (c1 * np.sin(k1 * L1) * np.cos(k2 * L2) + + c2 * np.cos(k1 * L1) * np.sin(k2 * L2)) + + +def analytic_frequencies(c1: float, L1: float, c2: float, L2: float, + f_max_hz: float, samples_per_period: int = 40) -> List[float]: + """All positive roots of the dispersion relation below ``f_max_hz``. + + Dense-sample ``g`` finely enough to resolve its fastest oscillation (set by the + shorter, faster zone) and Brent's method to refine each sign change found. Because + ``g`` (unlike the raw tan-tan form) has no poles, every sign change IS a root — + no risk of mistaking an asymptote for a mode, which is exactly why this pole-free + form was used instead of the more directly-derived tan-tan equation. + """ + # g's fastest oscillation in frequency is set by whichever zone reaches a quarter-wave + # resonance soonest as f increases, i.e. the zone with the smaller c/(4L) -- sample + # several points per that period so no root (sign change) is skipped over. + c_min = min(c1, c2) + L_min = min(L1, L2) + f_period = c_min / (4.0 * L_min) + df = f_period / samples_per_period + n_samples = max(int(np.ceil(f_max_hz / df)) + 2, 100) + f_grid = np.linspace(1e-3, f_max_hz, n_samples) # start just above 0 (f=0 is a trivial root) + g_grid = np.array([_dispersion_g(f, c1, L1, c2, L2) for f in f_grid]) + + roots = [] + for i in range(len(f_grid) - 1): + if g_grid[i] == 0.0: + roots.append(f_grid[i]) + elif g_grid[i] * g_grid[i + 1] < 0.0: + root = brentq(_dispersion_g, f_grid[i], f_grid[i + 1], args=(c1, L1, c2, L2)) + roots.append(root) + return sorted(roots) + + +def run_case(*, c1: float, L1: float, c2: float, L2: float, R: float, + nx1: int, nx2: int, nr: int, n_compare: int = 4) -> List[ModeComparison]: + """Solve the FEM two-zone duct at m=0 and match the lowest ``n_compare`` modes + against the dispersion-relation roots (same sort-and-pair strategy as V1; see the + completeness pitfall noted in ``docs/stability/stability_hifi_p0_v1_notes.md`` -- + the analytic root search above already returns every root up to ``f_max_hz``, with + no truncation-by-branch that could silently skip one, so that specific bug class + does not apply here, but the general "did I search far enough" question still does, + hence ``f_max_hz`` below is set generously past the highest mode we intend to compare). + """ + # A rough frequency ceiling to search up to: comfortably past where we expect the + # n_compare-th mode. Uniform-limit estimate n*c_avg/(2L) as a starting scale, x3 margin. + c_avg = 0.5 * (c1 + c2) + L = L1 + L2 + f_max_guess = 3.0 * n_compare * c_avg / (2.0 * L) + candidates = analytic_frequencies(c1, L1, c2, L2, f_max_guess)[:n_compare] + + spec = synthetic_two_zone_duct(L1, L2, R, nx1, nx2, nr, c1=c1, c2=c2) + sigma = (2.0 * np.pi * 0.5 * candidates[0]) ** 2 # shift below the lowest true mode + freqs_hz, _ = solve_passive_modes(spec.mesh, spec.c_element, m=0, + n_modes=n_compare + 2, sigma=sigma) + + f_min = candidates[0] + freqs_hz = np.sort(freqs_hz[freqs_hz > 0.05 * f_min])[:n_compare] + + return [ModeComparison(f_analytic_hz=float(fa), f_fem_hz=float(ff)) + for fa, ff in zip(candidates, freqs_hz)] diff --git a/EngineDesign/engine/stability_hifi/validation/v3_ntau_duct.py b/EngineDesign/engine/stability_hifi/validation/v3_ntau_duct.py new file mode 100644 index 000000000..fb320d224 --- /dev/null +++ b/EngineDesign/engine/stability_hifi/validation/v3_ntau_duct.py @@ -0,0 +1,202 @@ +"""Verification case V3: duct with a compact n-tau flame, closed/choked ends +(paper Section VII.A: "active flame term, delay nonlinearity, complex lambda, +all three solvers agree"). + +This is the first case where the eigenproblem is genuinely NONLINEAR in lambda +(the flame delay exp(-lambda*tau), Eq. 7a/11), so it is the first exercise of the +paper's Section IV solver hierarchy: the frozen-delay fixed point (Algorithm 1) and +the bordered Newton polish (Section IV.C). (The third solver of the hierarchy, Beyn +contour integration, is a completeness AUDIT rather than a mode solver and is deferred +to P3 per the paper's phasing — so "all three solvers" is, at P0, these two. Flagged +rather than silently reinterpreted.) + +Configuration +------------- +A duct of length L, uniform sound speed c, rigid ("closed") end at x=0, compact +choked-nozzle admittance y_noz = (gamma-1)*Mbar_e/2 (Appendix C) at x=L, and a compact +(delta-in-x, uniform-in-r) pressure-coupled flame at x = x_f: + + q_hat(x) = gain * exp(-lambda*tau) * p_hat(x_f) * delta(x - x_f) + +i.e. Eq. (7a) lumped to a single reference point and a single flame sheet, with +``gain`` absorbing (gamma-1) * n_p * (qbar_dot/pbar) * (flame axial thickness) +[units m/s]. The flame is uniform across the cross-section, so for the r-independent +longitudinal modes compared here the 2-D axisymmetric FEM and the 1-D analytic model +below describe *identical* physics. + +Discrete form solved (Eq. 11, rank-1 flame): + + N(lambda) p = [K + lambda*C + lambda^2*M2 - lambda*gain*exp(-lambda*tau)*g b^T] p = 0 + +with b = point sample of p at (x_f, 0) and g = the r-weighted disk load at x_f +(two DIFFERENT vectors; see acoustics/assembly.py for why conflating them is a bug). + +Analytic reference (derived, not from the paper) +------------------------------------------------ +Zone solutions satisfying the end conditions, with s = lambda/c: + + zone 1 (0..x_f): P(x) = cosh(s*x) [P'(0)=0, rigid] + zone 2 (x_f..L): Q(x) = r2*exp(s*(x-L)) + exp(-s*(x-L)) + +The admittance end condition p'(L) = -s*y_noz*p(L) (from Eq. 8 with y constant) gives + + r2 = (1 - y_noz) / (1 + y_noz) [lambda-independent for a compact nozzle]. + +CAUTION (documented wrong turn): an earlier draft had r2 = (y-s')/(y+s') -> the +NEGATIVE of the correct value. The passive limit exposes it: with the wrong sign the +flame-off roots do not reproduce the independently verified tanh(s*L) = -y_noz +spectrum (sigma ~ -y*c/L, f ~ n*c/2L). Always check the passive limit of an +active-flame dispersion relation first. + +At the flame, p is continuous and the slope jumps. The jump follows from the FEM's own +strong form (integrate lambda^2*p - d/dx(c^2 p') = lambda*gain*e^{-lambda*tau} +* p(x_f) * delta(x-x_f) across the sheet): + + [p']_{x_f-}^{x_f+} = -lambda * beta * exp(-lambda*tau) * p(x_f), + beta = gain / c^2. + +CAUTION (the bug that cost the most time): beta is gain/c^2, NOT gain*(R^2/2)/c^2. +The r-weighted disk load g sums to R^2/2, but the r dOmega measure multiplies every +OTHER matrix (K, M2) by the same R^2/2, so it cancels exactly in the 1-D reduction. +Double-counting it made the analytic flame coupling 1/(R^2/2) = 5000x too weak, which +presented as "FEM growth rate 5000x larger than analytic" — with frequencies agreeing +to 0.003%, the signature of a coupling-scale error rather than a discretization or +solver error. (The FEM answer was mesh-converged and confirmed independently by +first-order eigenvalue perturbation theory of the discrete system; the closed-form +sensitivity for the fundamental mode of the rigid-rigid case, +d(lambda)/d(gain) = exp(-lambda0*tau) * cos^2(pi*x_f/L) / L, in which R cancels +entirely, is re-checked in the test suite.) + +Eliminating the two amplitudes with the two interface conditions gives the dispersion +relation, root-found in pole-free product form (V2's lesson): + + G(lambda) = P'(x_f) Q(x_f) - Q'(x_f) P(x_f) + - lambda*beta*exp(-lambda*tau) * P(x_f) Q(x_f) = 0. + +Phase convention worth knowing (physics finding, verified numerically three ways): +with the pure-delay coupling (7a), the Rayleigh driving of a mode goes as ++cos(omega*tau) — in-phase heat release drives, anti-phase damps — because the +cycle-averaged p'q' is |p_hat|^2 * cos(omega*tau). The sin(omega*tau) rule used by the +suite's lumped model (engine/pipeline/stability/acoustic.py) belongs to the DIFFERENCE +form of the Crocco response, n*[p'(t) - p'(t-tau)], whose transfer n*(1-e^{-i*omega*tau}) +has imaginary part n*sin(omega*tau). Both are legitimate n-tau models but their +instability tau-bands sit a quarter-period apart; comparisons between this framework +and the lumped tier must translate conventions first. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from engine.stability_hifi.acoustics.assembly import ( + assemble_boundary_admittance, + assemble_passive, + disk_load_vector, + point_sampling_vector, +) +from engine.stability_hifi.acoustics.mesh import two_zone_duct_mesh +from engine.stability_hifi.eigen.nlevp import fixed_point_solve, newton_polish, residual_N + + +@dataclass +class V3Result: + lam_fixed_point: complex # frozen-delay fixed point (Algorithm 1) + lam_newton: complex # after bordered Newton polish (Section IV.C) + lam_analytic: complex # root of the 1-D dispersion relation + fp_iterations: int + newton_iterations: int + nlevp_residual: float # ||N(lam) p|| / ||p|| at the Newton answer + + @property + def solver_agreement(self) -> float: + """|lam_fp - lam_newton| / |lam_newton| — paper's V3 acceptance: < 1e-6.""" + return abs(self.lam_fixed_point - self.lam_newton) / abs(self.lam_newton) + + @property + def rel_error_vs_analytic(self) -> float: + """|lam_newton - lam_analytic| / |lam_analytic| — paper's V3 acceptance: < 1%.""" + return abs(self.lam_newton - self.lam_analytic) / abs(self.lam_analytic) + + +def dispersion_G(lam: complex, *, c: float, L: float, x_f: float, y_noz: float, + beta: float, tau: float) -> complex: + """Pole-free dispersion residual G(lambda) (module docstring). Root <=> eigenvalue.""" + s = lam / c + xi = x_f - L + r2 = (1.0 - y_noz) / (1.0 + y_noz) + Pf = np.cosh(s * x_f) + Pfp = s * np.sinh(s * x_f) + Qf = r2 * np.exp(s * xi) + np.exp(-s * xi) + Qfp = s * (r2 * np.exp(s * xi) - np.exp(-s * xi)) + return Pfp * Qf - Qfp * Pf - lam * beta * np.exp(-lam * tau) * Pf * Qf + + +def dispersion_root(lam_seed: complex, *, c: float, L: float, x_f: float, y_noz: float, + beta: float, tau: float, iters: int = 60, max_step: float = 20.0 + ) -> complex: + """Bounded-step complex Newton on G, seeded near the expected root. + + Deliberately LOCAL: we want the dispersion root nearest the seed (the FEM + eigenvalue being cross-checked), and an unbounded/global root-finder can wander to + a distant root and report success — scipy.fsolve did exactly that during + development, converging to an unrelated real root thousands of rad/s away. + Guaranteed-complete root surveys are the job of the Beyn audit tier (P3), not of a + verification cross-check. The derivative is numerical (central difference, step + h=1e-2 — G is smooth on O(1) lambda scales, so this is far below its variation). + """ + lam = complex(lam_seed) + h = 1e-2 + for _ in range(iters): + d = dispersion_G(lam, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau) + dd = (dispersion_G(lam + h, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau) + - dispersion_G(lam - h, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau)) / (2.0 * h) + step = d / dd + if abs(step) > max_step: + step = step / abs(step) * max_step + lam = lam - step + return lam + + +def run_case(*, c: float, L: float, R: float, x_f: float, gamma: float, Mbar_e: float, + flame_gain: float, tau: float, nx1: int, nx2: int, nr: int, + rigid_end: bool = False) -> V3Result: + """Full V3 run: FEM NLEVP (both solvers) vs the 1-D dispersion relation. + + ``rigid_end=True`` replaces the choked-nozzle admittance at x=L with a rigid wall + (y_noz = 0) — the pure-flame sub-case, useful for isolating flame handling from + boundary-damping handling when something disagrees. + + The mesh is built with ``two_zone_duct_mesh`` even though there is no material + jump, purely to guarantee an exact node column at x = x_f for the disk load + (see ``disk_load_vector``'s docstring). + """ + y_noz = 0.0 if rigid_end else (gamma - 1.0) * Mbar_e / 2.0 + beta = flame_gain / c ** 2 # NOT /(R^2/2) — see module docstring, the costly bug + + mesh, _zone = two_zone_duct_mesh(x_f, L - x_f, R, nx1, nx2, nr) + c_field = np.full(mesh.n_nodes, c) + K, _Km, M2 = assemble_passive(mesh, c_field, m=0) + K, M2 = K.toarray(), M2.toarray() + if rigid_end: + C = np.zeros_like(K) + else: + C = assemble_boundary_admittance(mesh, mesh.nodes_at_x(L), + coefficient=c * y_noz).toarray() + g = disk_load_vector(mesh, mesh.nodes_at_x(x_f)) + b = point_sampling_vector(mesh, x_f, 0.0) + + # Seed at the passive closed/choked fundamental: f ~ c/2L, sigma ~ -y*c/L + # (Algorithm 2 step 5's "passive modes seed the shifts", in miniature). + lam_seed = complex(-y_noz * c / L, 2.0 * np.pi * c / (2.0 * L)) + + lam_fp, p_fp, n_fp = fixed_point_solve(K, C, M2, flame_gain, tau, g, b, lam_seed) + lam_nw, p_nw, n_nw = newton_polish(K, C, M2, flame_gain, tau, g, b, lam_fp, p_fp) + res = residual_N(K, C, M2, flame_gain, tau, g, b, lam_nw, p_nw) + res_norm = float(np.linalg.norm(res) / np.linalg.norm(p_nw)) + + lam_an = dispersion_root(lam_nw, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau) + + return V3Result(lam_fixed_point=lam_fp, lam_newton=lam_nw, lam_analytic=lam_an, + fp_iterations=n_fp, newton_iterations=n_nw, nlevp_residual=res_norm) diff --git a/EngineDesign/frontend/src/App.tsx b/EngineDesign/frontend/src/App.tsx index a509dd39b..5e3c79af2 100644 --- a/EngineDesign/frontend/src/App.tsx +++ b/EngineDesign/frontend/src/App.tsx @@ -13,10 +13,16 @@ import ConfigurationSelector from './components/ConfigurationSelector'; import { emitConfigChanged } from './lib/configBus'; import { useViewState } from './lib/viewState'; import { DesignVersions } from './components/DesignVersions'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { ReadOnlyProvider } from '@stardesign-ui'; import { getConfig, getHealth } from './api/client'; import type { EngineConfig } from './api/client'; +// Injected by vite.config.ts from ENGINE_DESIGN_API_PORT, so the reconnect +// hint names the port this build actually proxies to. +declare const __API_PORT__: string +const API_PORT = typeof __API_PORT__ === 'undefined' ? '8000' : __API_PORT__ + type Tab = | 'forward' | 'timeseries' @@ -228,8 +234,8 @@ function App() {

Backend not connected

-

Make sure the FastAPI server is running on port 8000

- uvicorn backend.main:app --reload --port 8000 +

Make sure the FastAPI server is running on port {API_PORT}

+ uvicorn backend.main:app --reload --port {API_PORT}
@@ -237,117 +243,135 @@ function App() { {/* Keep all tab panels mounted; hide inactive ones to preserve state */}
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
- + + +
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {/* Upload section - compact */} -
-
-
- -
- {config && ( -
- - - - Config loaded and ready + +
+ {/* Upload section - compact */} +
+
+
+
- )} + {config && ( +
+ + + + Config loaded and ready +
+ )} +
-
- {/* Editor section - full width */} -
- + {/* Editor section - full width */} +
+ +
-
+
diff --git a/EngineDesign/frontend/src/api/client.ts b/EngineDesign/frontend/src/api/client.ts index afceb4590..b194eaffa 100644 --- a/EngineDesign/frontend/src/api/client.ts +++ b/EngineDesign/frontend/src/api/client.ts @@ -1075,6 +1075,7 @@ export interface Layer1Results { final_change?: number; best_objective?: number; best_objective_breakdown?: Record; + infeasible_reason?: string | null; /** Thrust / O-F / P_exit relative errors and RMS (dimensionless); use for “true” physics convergence. */ primary_relative_residual?: { rel_thrust?: number; diff --git a/EngineDesign/frontend/src/components/ConfigEditor.tsx b/EngineDesign/frontend/src/components/ConfigEditor.tsx index 329b23209..f2bcc3e0d 100644 --- a/EngineDesign/frontend/src/components/ConfigEditor.tsx +++ b/EngineDesign/frontend/src/components/ConfigEditor.tsx @@ -10,28 +10,29 @@ interface ConfigEditorProps { } // Section metadata for better labels and descriptions -const SECTION_META: Record = { - fluids: { label: 'Fluids', icon: '💧', description: 'Oxidizer and fuel properties' }, - injector: { label: 'Injector', icon: '🔧', description: 'Injector geometry (pintle or impinging doublet)' }, - feed_system: { label: 'Feed System', icon: '⚡', description: 'Propellant feed configuration' }, - regen_cooling: { label: 'Regenerative Cooling', icon: '❄️', description: 'Cooling channel parameters' }, - film_cooling: { label: 'Film Cooling', icon: '🌊', description: 'Film cooling settings' }, - ablative_cooling: { label: 'Ablative Cooling', icon: '🔥', description: 'Ablative material properties' }, - graphite_insert: { label: 'Graphite Insert', icon: '⬛', description: 'Throat insert configuration' }, - stainless_steel_case: { label: 'Steel Case', icon: '🔩', description: 'Case material properties' }, - discharge: { label: 'Discharge Coefficients', icon: '📊', description: 'Cd models for oxidizer/fuel' }, - spray: { label: 'Spray Modeling', icon: '💨', description: 'Atomization and spray parameters' }, - combustion: { label: 'Combustion', icon: '🔥', description: 'CEA and efficiency models' }, - chamber_geometry: { label: 'Chamber Geometry (Unified)', icon: '🎯', description: 'Unified chamber and nozzle design parameters' }, - chamber: { label: 'Chamber', icon: '🎯', description: 'Combustion chamber geometry' }, - nozzle: { label: 'Nozzle', icon: '🚀', description: 'Nozzle expansion parameters' }, - solver: { label: 'Solver', icon: '⚙️', description: 'Numerical solver settings' }, - lox_tank: { label: 'LOX Tank', icon: '🛢️', description: 'Oxidizer tank geometry' }, - fuel_tank: { label: 'Fuel Tank', icon: '⛽', description: 'Fuel tank geometry' }, - press_tank: { label: 'Pressurization Tank', icon: '🎈', description: 'Pressurant system' }, - rocket: { label: 'Rocket', icon: '🚀', description: 'Vehicle mass and geometry' }, - environment: { label: 'Environment', icon: '🌍', description: 'Launch site conditions' }, - thrust: { label: 'Thrust Profile', icon: '📈', description: 'Burn duration settings' }, +const SECTION_META: Record = { + fluids: { label: 'Fluids', description: 'Oxidizer and fuel properties' }, + injector: { label: 'Injector', description: 'Injector geometry (pintle or impinging doublet)' }, + feed_system: { label: 'Feed System', description: 'Propellant feed configuration' }, + regen_cooling: { label: 'Regenerative Cooling', description: 'Cooling channel parameters' }, + film_cooling: { label: 'Film Cooling', description: 'Film cooling settings' }, + ablative_cooling: { label: 'Ablative Cooling', description: 'Ablative material properties' }, + graphite_insert: { label: 'Graphite Insert', description: 'Throat insert configuration' }, + stainless_steel_case: { label: 'Steel Case', description: 'Case material properties' }, + discharge: { label: 'Discharge Coefficients', description: 'Cd models for oxidizer/fuel' }, + spray: { label: 'Spray Modeling', description: 'Atomization and spray parameters' }, + combustion: { label: 'Combustion', description: 'CEA and efficiency models' }, + chamber_geometry: { label: 'Chamber Geometry (Unified)', description: 'Unified chamber and nozzle design parameters' }, + chamber: { label: 'Chamber', description: 'Combustion chamber geometry' }, + nozzle: { label: 'Nozzle', description: 'Nozzle expansion parameters' }, + solver: { label: 'Solver', description: 'Numerical solver settings' }, + stability: { label: 'Stability Model', description: 'Combustion-response calibration, regulator dynamics, acoustic damping' }, + lox_tank: { label: 'LOX Tank', description: 'Oxidizer tank geometry' }, + fuel_tank: { label: 'Fuel Tank', description: 'Fuel tank geometry' }, + press_tank: { label: 'Pressurization Tank', description: 'Pressurant system' }, + rocket: { label: 'Rocket', description: 'Vehicle mass and geometry' }, + environment: { label: 'Environment', description: 'Launch site conditions' }, + thrust: { label: 'Thrust Profile', description: 'Burn duration settings' }, }; // Human-readable field labels @@ -60,6 +61,18 @@ const FIELD_LABELS: Record = { spacing: 'Element Spacing (m)', d_inlet: 'Inlet Diameter (m)', line_size: 'Feed Line Size', + // Stability model inputs (StabilityConfig) + n_interaction: 'Interaction Index n', + chi_acoustic: 'Sensitive-Lag Fraction χ', + mach_nozzle_entrance: 'Nozzle-Entrance Mach (blank = from contraction ratio)', + damping_injector_frac: 'Injector Damping Fraction', + damping_twophase_frac: 'Two-Phase Damping Fraction', + droplet_loading: 'Droplet Loading', + acoustic_gate_alpha_offset: 'Acoustic Gate Allowance (1/s)', + regulator_enabled: 'Model Dome Regulator', + regulator_corner_hz: 'Regulator Corner Frequency (Hz)', + regulator_Z_hf: 'Regulator HF Impedance (Pa·s/kg)', + regulator_max_excursion_psi: 'Regulator Excursion Bound (psi)', // Evaporation / spray-length model C_evap: 'Evaporation Calibration Constant', cp_gas: 'Combustion Gas cp (J/kg·K)', @@ -454,7 +467,6 @@ function SectionCard({ sectionKey, data, onEdit }: SectionCardProps) { const [isExpanded, setIsExpanded] = useViewState(`configSection.${sectionKey}`, false); const meta = SECTION_META[sectionKey] || { label: sectionKey.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), - icon: '📄', description: '', }; @@ -462,7 +474,6 @@ function SectionCard({ sectionKey, data, onEdit }: SectionCardProps) { return (
- {meta.icon}

{meta.label}

Not configured

@@ -519,7 +530,6 @@ function SectionCard({ sectionKey, data, onEdit }: SectionCardProps) { className="w-full flex items-center justify-between p-4 hover:bg-[var(--color-bg-tertiary)] transition-colors" >
- {meta.icon}

{meta.label}

diff --git a/EngineDesign/frontend/src/components/ConfigurationSelector.tsx b/EngineDesign/frontend/src/components/ConfigurationSelector.tsx index 03e78dfc8..542f58f96 100644 --- a/EngineDesign/frontend/src/components/ConfigurationSelector.tsx +++ b/EngineDesign/frontend/src/components/ConfigurationSelector.tsx @@ -3,6 +3,9 @@ import { getSwitchOptions, switchConfig, type SwitchOptions, type EngineConfig } import { emitConfigChanged } from '../lib/configBus'; import { useReadOnly } from '@stardesign-ui'; +declare const __API_PORT__: string +const API_PORT = typeof __API_PORT__ === 'undefined' ? '8000' : __API_PORT__ + /** * First-class injector + propellant selectors (UNIFICATION P6). * @@ -14,7 +17,6 @@ import { useReadOnly } from '@stardesign-ui'; const PRETTY: Record = { pintle: 'Pintle', impinging: 'Doublet (unlike-impinging)', - coaxial: 'Coaxial', methalox: 'Methalox (LOX / CH₄)', ethalox: 'Ethalox (LOX / Ethanol)', kerolox: 'Kerolox (LOX / RP-1)', @@ -89,7 +91,7 @@ export default function ConfigurationSelector({ onConfigChange }: Props) {

Propellant / Injector - {error ? '(start backend on :8000)' : 'loading…'} + {error ? `(start backend on :${API_PORT})` : 'loading…'}
); diff --git a/EngineDesign/frontend/src/components/ControllerMode.tsx b/EngineDesign/frontend/src/components/ControllerMode.tsx index b354c437f..c1157213c 100644 --- a/EngineDesign/frontend/src/components/ControllerMode.tsx +++ b/EngineDesign/frontend/src/components/ControllerMode.tsx @@ -542,7 +542,7 @@ export function ControllerMode({ config }: ControllerModeProps) {