# Engine-Cycle Simulation This tutorial goes over a complete engine cycle, using an example of a dual-expander methane/oxygen cycle. It assumes that you are already comfortable with the thrust-chamber, cooling-circuit, and regenerative heat-transfer objects introduced in {doc}`Minimal Simulation `. The complete example consists of two scripts: ```text examples/advanced/ ├── sizer_sim.py └── post_process.py ``` `RUN_MODE = "regen_only"` is useful while developing the thrust chamber: it runs the regenerative circuits with explicit inlet boundary conditions and does not construct the complete cycle. This tutorial concentrates on `RUN_MODE = "full_cycle"`, where the cooling circuits are embedded in a coupled pump–regenerator–turbine–injector network. Run the simulation and report generator from the repository root: ```console python examples/advanced/sizer_sim.py python examples/advanced/post_process.py ``` The simulation makes a result object, and saves it in `results.pkl`. Post-processing reads that file and writes `methane_engine_report.html`, as well as the plots shown in this tutorial. All the plots you see in this tutorial can be found embedded in the report html. The html reports collects the data generated by pyskyfire in an easy to navigate and flexible format. The last part of this tutorial goes through how to set up and save a report. ## Define the design point `make_params()` contains the editable numerical and physical inputs. It holds four kinds of data: - chamber design point, propellants, contour parameters, wall stack, and cooling geometry; - tank pressures, pump efficiencies and speeds, turbine efficiencies, and duct pressure ratios; - small recirculation and turbine-bypass fractions; - independent inlet conditions for the standalone regeneration mode. ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:engine-inputs :end-before: tutorial:end:engine-inputs ``` The combustion propellants and coolant propellants are both defined because the hot-gas model and coolant-property model use different backends. The parameter dictionary serves as the single source of design assumptions. ## Build the thrust chamber The next thing to do is to build a thrust chamber. This can be a bit of an iterative process, getting acceptable regenerative cooling while keeping pressure drops to a minimum. Use `regen_only` mode to get the thrust chamber right. Since this is an expander cycle engine, heat pickup in the chamber is also important. Notice how a high chamber aspect ratio increases the surface area of the chamber, and thereby the power extracted for the cycle. Since this is a dual expander cycle, several cooling circuits are defined around the chamber for both the fuel and the oxidizer to flow through. The block below computes the combustion-gas transport model, generates the contour, defines walls and cooling circuits, and combines them into one `ThrustChamber`. ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:thrust-chamber :end-before: tutorial:end:thrust-chamber ``` In principle, the thrust chamber aerothermodynamic properties are dependent on the condition of the propellants at the inlet of the engine. It could therefore be a part of the simulation loop. However, the computational cost to this is great, and the added precision it gives is marginal. It is therefore more practical to guess the inlet conditions to the thrust chamber, and perhaps update them once the inlet conditions to the thrust chamber has been established by running the simulation. Looking at the thrust chamber contour, one can notice the high aspect ratio in the combustion chamber section, contributing to increased heat pickup: ```{raw} html
``` This example contains one fuel chamber pass and two serial oxidizer nozzle passes. The fuel enters just downstream of the throat and travels toward the injector. The oxidizer enters just downstream of the throat, travels to the nozzle exit, and then returns to its starting axial position before entering the turbine. All three circuits use uncoated copper walls. Inspect the 3D engine below: ```{raw} html
``` ## Initial station guesses A full engine cycle has fluid states at every component interface. Pyskyfire represents each state as a `Station(p, T, mdot)`, containing pressure in Pa, temperature in K, and mass flow in kg/s. The network needs an initial station dictionary before it can converge: ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:initial-stations :end-before: tutorial:end:initial-stations ``` The station names are not merely labels. They are the links between blocks: a block reads its named inlet stations and writes its named outlet stations. For example, the fuel pump writes `fu_pump_out`; the recirculation splitter then reads `fu_pump_out` and writes `fu_regen_duct_in` and `fu_shaft_recirc`. These values are **initial guesses**, not prescribed results. They should nevertheless be physically credible. A useful starting point follows the expected trend through the engine: - pressure rises across a pump and decreases across ducts, cooling circuits, turbines, and injectors; - temperature rises through regenerative cooling and decreases across a turbine; - mass flow follows the intended split, merge, recirculation, and bypass fractions. A fixed-point solver is not a global optimiser that can reliably recover from arbitrary guesses. If an early sweep sends a fluid to an invalid thermodynamic state, makes a turbine outlet temperature non-physical, or generates an enormous pressure change, property calls and the regenerative solver may fail before the network has a chance to settle. Use rough but reasonable values; they do not need to be accurate final-cycle predictions. ## Initial scalar signals Stations represent flowing propellant. Signals represent scalar quantities shared between blocks but not carried by a single fluid stream: pump power, turbine power requirement, pressure drops, targets, or future control variables. ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:initial-signals :end-before: tutorial:end:initial-signals ``` This example begins with estimates for the fuel and oxidizer turbine power requirements. The transmission blocks later replace them with the pump loads calculated by the network. Pressure-drop signals are added separately after the blocks are created because each loss-producing block declares its own `dp_key`. ## Create the engine network The network setup first gathers the station guesses, scalar signals, and component blocks. ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:network-setup :end-before: tutorial:end:network-setup ``` Each block advertises the station and signal keys it consumes and emits. The `EngineNetwork` executes the blocks in the order supplied by the list, merges each block's outputs into its station and signal dictionaries, and measures the maximum relative change in the updated quantities. At present, Pyskyfire preserves the supplied block order. It does not yet perform a dependency-aware topological sort. Arrange the blocks in a valid flow order: merge or source, pump, splitter, ducts and cooling passages, turbine, downstream ducts, injector, then transmission coupling. ### Fluid and signal blocks The example uses the following block types. | Block | Role in the network | |---|---| | `MassFlowMergerBlock` | Combines same-fluid branches by mass and enthalpy. | | `PumpBlock` | Raises pressure to meet its required load and emits the pump shaft-power signal. | | `MassFlowSplitterBlock` | Divides a stream into fixed fractions; the branch pressure and temperature are unchanged by the ideal split itself. | | `SimpleDuctBlock` | Applies a fixed pressure ratio and an adiabatic, constant-enthalpy pressure loss. | | `RegenBlock` | Runs a regenerative-cooling circuit using its inlet station, writes its outlet station, and emits its pressure drop. | | `TurbineBlock` | Expands the inlet stream enough to deliver the shaft-power signal requested by the transmission. | | `TransmissionBlock` | Sums shaft power demands and writes the power signal consumed by the turbine. | ### Fuel-side blocks and the pump load The fuel-side block sequence reads much like an engine flow schematic: ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:fuel-side-blocks :end-before: tutorial:end:fuel-side-blocks ``` `PumpBlock.overcome` deserves special attention. It lists the downstream blocks whose pressure losses the pump must overcome: ```python overcome=[ "Duct Pump-Regen Fuel", "Regen Fuel Chamber Pass", "Duct Regen-Turbine Fuel", "Fuel Turbine", "Duct Turbine-Injector Fuel", "Fu Injector", ] ``` A pressure-loss block named `Regen Fuel Chamber Pass` writes a signal named `dp_Regen Fuel Chamber Pass`. The pump collects `dp_` for every listed item, adds the baseline chamber-side pressure requirement, and determines the target pump outlet pressure. Its required shaft power is then emitted as `P_Fuel Pump`. This is somewhat clunky. The pressure path is not inferred automatically from the network graph, so modifying the cycle topology requires manually updating `overcome`. It remains the current solution because it keeps the block models local and the fixed-point solver simple: the pump only needs scalar pressure-drop signals, rather than a general graph traversal and algebraic loop formulation. Treat each `overcome` list as part of the cycle definition and review it whenever a component is added, removed, bypassed, or moved. ### Oxidizer-side blocks The oxidizer side uses two pump stages. Stage 1 pumps the complete oxidizer flow only high enough to supply the common injector path. After the recirculation split, `ox_regen_flow_fraction` selects the fraction sent through stage 2; the example uses `0.5`. The remaining oxidizer bypasses the regenerative branch and proceeds directly toward the chamber. Stage 2 pumps only the selected fraction to the higher pressure needed by the two serial nozzle cooling passes and oxidizer turbine. Its `overcome` list consequently contains only components in that high-pressure branch. After turbine expansion, the heated branch recombines with the direct branch before the shared downstream duct and injector. The oxidizer transmission sums the shaft powers of both pump stages when setting the turbine power requirement: ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:oxidizer-side-blocks :end-before: tutorial:end:oxidizer-side-blocks ``` This split avoids raising the entire oxidizer flow to the regenerative-circuit pressure. Change `ox_regen_flow_fraction` to explore the tradeoff between coolant/turbine mass flow and second-stage pump demand. Keeping track of which block is connected where can be difficult in the script view. Therefore, pyskyfire implements visualisation to view the engine network you have created. A visualisation of the above network is shown below: ```{raw} html
``` ### Seed pressure-drop signals and solve Before the first iteration, the script creates initial pressure-drop signals from the station guesses. ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:pressure-drop-signals :end-before: tutorial:end:pressure-drop-signals ``` This bootstrap step exists because the pumps read downstream pressure-drop signals on their first sweep, while those signals are only calculated by the loss-producing blocks during the sweep. The initial values do not need to be exact, but they should be non-negative and consistent with the station guesses. The network is then constructed and solved: ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:network-solve :end-before: tutorial:end:network-solve ``` During one fixed-point sweep, every block receives the current station and signal dictionaries, computes its outputs, and overwrites the corresponding dictionary entries. Pyskyfire records the largest relative update among pressure, temperature, mass flow, and scalar signals. It repeats complete sweeps until that residual falls below `tol` or the iteration limit is reached. A converged network performs one additional **post-process sweep**. Every block has a `post_process(stations, signals)` hook. Most blocks return an empty dictionary because their ordinary station result is already sufficient. `RegenBlock` uses this hook to rerun its cooling calculation on a detailed axial grid using the final converged inlet condition. The resulting temperature, pressure, heat-flux, and wall-temperature profiles are collected in `net.block_results`, keyed by block name. This split is important: a network iteration only needs enough information to update the coupled cycle. Detailed axial arrays are more expensive and are therefore generated after convergence, once rather than once per fixed-point sweep. ## Normalize cooling results and save the simulation The standalone regeneration path returns named cooling data directly. The full-cycle path obtains equivalent cooling data from the regenerative blocks' post-process outputs: ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:full-cycle-cooling-data :end-before: tutorial:end:full-cycle-cooling-data ``` The main function selects the mode, runs the requested calculation, and saves a portable `Results` object. ```{literalinclude} ../../examples/advanced/sizer_sim.py :language: python :start-after: tutorial:start:run-and-save :end-before: tutorial:end:run-and-save ``` Both modes save the common result contract: ```text mode params thrust_chamber cooling_data ``` A full-cycle result also stores: ```text net stations signals residuals block_results ``` Saving results separates the expensive calculation from visualisation. You can run the cycle once, inspect the residual history, then adjust graph selection, captions, plot order, or report layout repeatedly without rerunning the thermal solver and fixed-point cycle. ## Generate the report `post_process.py` loads `results.pkl` and first generates report tabs that are meaningful for either run mode: input parameters, thrust-chamber geometry, cooling data, combustion properties, and through-wall thermal gradients. ```{literalinclude} ../../examples/advanced/post_process.py :language: python :start-after: tutorial:start:common-report :end-before: tutorial:end:common-report ``` For a full-cycle result, the post-processing script adds a dedicated cycle tab containing the convergence history, station pressure/temperature/mass-flow plots, and fuel/oxidizer pressure-temperature paths. ```{literalinclude} ../../examples/advanced/post_process.py :language: python :start-after: tutorial:start:cycle-report :end-before: tutorial:end:cycle-report ``` Finally, the script validates the saved result contract, chooses the appropriate report content from `mode`, and writes the HTML report. ```{literalinclude} ../../examples/advanced/post_process.py :language: python :start-after: tutorial:start:generate-report :end-before: tutorial:end:generate-report ``` A regeneration-only result produces a thrust-chamber and cooling report without an engine-cycle tab. A full-cycle result includes both the chamber analysis and the coupled-cycle diagnostics. You can view the report here: Engine Cycle Report