From f61337749c631774c9c5242ae742d513975e955e Mon Sep 17 00:00:00 2001 From: Triet Ho Date: Wed, 16 Sep 2026 18:54:38 -0400 Subject: [PATCH 1/6] Check propulsion edge power consistency --- +PropulsionPkg/CheckEdgePowerConsistency.m | 62 +++++ +PropulsionPkg/Contents.m | 2 + +PropulsionPkg/PropAnalysis.m | 21 +- +PropulsionPkg/TestEdgePowerConsistency.m | 249 +++++++++++++++++++++ TestFAST.m | 1 + 5 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 +PropulsionPkg/CheckEdgePowerConsistency.m create mode 100644 +PropulsionPkg/TestEdgePowerConsistency.m diff --git a/+PropulsionPkg/CheckEdgePowerConsistency.m b/+PropulsionPkg/CheckEdgePowerConsistency.m new file mode 100644 index 0000000..7eebb19 --- /dev/null +++ b/+PropulsionPkg/CheckEdgePowerConsistency.m @@ -0,0 +1,62 @@ +function [] = CheckEdgePowerConsistency(Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower) +% +% [] = CheckEdgePowerConsistency(Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower) +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Check that upstream and downstream matrices describe the same power on +% every active edge at one mission control point. Downstream propagation +% gives the component powers needed to meet the sink demand. Each edge's +% delivered power must then agree with upstream propagation from its source. +% +% INPUTS: +% Arch - source-to-target propulsion architecture matrix. +% size/type/units: n-by-n / logical or double / [] +% LamUps - evaluated upstream operational matrix. +% size/type/units: n-by-n / double / [] +% LamDwn - evaluated downstream operational matrix. +% size/type/units: n-by-n / double / [] +% EtaUps - upstream edge efficiencies. +% size/type/units: n-by-n / double / [] +% EtaDwn - downstream edge efficiencies. +% size/type/units: n-by-n / double / [] +% SinkPower - power required at the final sink. +% size/type/units: 1-by-1 / double / [W] +% +% OUTPUTS: +% none; throws an error when an edge is inconsistent. +% + +% An infinite demand is handled by PropAnalysis as an infeasible mission +% point, so it has no finite edge flow to compare. +if (isinf(SinkPower)) + return; +end + +% Use the same downstream propagation as the mission analysis, including +% path losses, to recover power at every component from the sink demand. +ncomp = length(Arch); +Power = zeros(ncomp, 1); +Power(end) = SinkPower; +Power = PropulsionPkg.PowerFlow(Power, Arch', LamDwn, EtaDwn, -1); + +% Compare delivered power on each source-to-target edge. The downstream +% split is a fraction of target demand; the upstream split is a fraction +% of source power and must include the upstream edge efficiency. +[Source, Target] = find(Arch); +DownIndex = sub2ind([ncomp, ncomp], Target, Source); +UpIndex = sub2ind([ncomp, ncomp], Source, Target); +DownEdge = Power(Target) .* LamDwn(DownIndex); +UpEdge = Power(Source) .* LamUps(UpIndex) .* EtaUps(UpIndex); + +% Match PowerFlow's 1e-6 W convergence floor while allowing small +% relative rounding error at large power. Row sums alone cannot detect +% mismatched individual edge flows. +Tolerance = max(1.0e-06, 1.0e-08 * max(abs(DownEdge), abs(UpEdge))); +if (any(~isfinite(DownEdge)) || any(~isfinite(UpEdge)) || ... + any(abs(DownEdge - UpEdge) > Tolerance)) + error("FAST:EdgePowerInconsistent", ... + "Edge power is not consistent. Your power splits are over-constrained."); +end + +end diff --git a/+PropulsionPkg/Contents.m b/+PropulsionPkg/Contents.m index 7311193..d910fc8 100644 --- a/+PropulsionPkg/Contents.m +++ b/+PropulsionPkg/Contents.m @@ -4,6 +4,7 @@ % % Functions % ------------------------------------------------------------------------- +% CheckEdgePowerConsistency - PropulsionPkg.CheckEdgePowerConsistency is a function. % CreatePropArch - PropulsionPkg.CreatePropArch is a function. % EngineLapse - PropulsionPkg.EngineLapse is a function. % EvalSplit - PropulsionPkg.EvalSplit is a function. @@ -16,5 +17,6 @@ % PropulsionSizing - PropulsionPkg.PropulsionSizing is a function. % RecomputeSplits - PropulsionPkg.RecomputeSplits is a function. % TestCreatePropArch - PropulsionPkg.TestCreatePropArch is a function. +% TestEdgePowerConsistency - PropulsionPkg.TestEdgePowerConsistency is a function. % TestEngineMotorRegressions - PropulsionPkg.TestEngineMotorRegressions is a function. % TestPowerAvailable - PropulsionPkg.TestPowerAvailable is a function. diff --git a/+PropulsionPkg/PropAnalysis.m b/+PropulsionPkg/PropAnalysis.m index 7f0b71a..34f611d 100644 --- a/+PropulsionPkg/PropAnalysis.m +++ b/+PropulsionPkg/PropAnalysis.m @@ -2,7 +2,7 @@ % % [Aircraft] = PropAnalysis(Aircraft) % written by Paul Mokotoff, prmoko@umich.edu -% last updated: 10 mar 2026 +% last updated: 16 sep 2026 % % Analyze the propulsion system for a given set of flight conditions. % Remember how the propulsion system performs in the mission history. @@ -47,9 +47,15 @@ % get the downstream operational matrix OperDwn = Aircraft.Specs.Propulsion.PropArch.OperDwn; +% get the upstream operational matrix for edge-flow consistency +OperUps = Aircraft.Specs.Propulsion.PropArch.OperUps; + % get the downstream efficiency matrix EtaDwn = Aircraft.Specs.Propulsion.PropArch.EtaDwn; +% get the upstream efficiency matrix +EtaUps = Aircraft.Specs.Propulsion.PropArch.EtaUps; + % get which propellers are connected to the gas turbine engines WhichProp = Aircraft.Specs.Propulsion.PropArch.WhichProp; @@ -118,10 +124,23 @@ % get the necessary splits LamDwn = Aircraft.Mission.History.SI.Power.LamDwn(SegBeg:SegEnd, :); +LamUps = Aircraft.Mission.History.SI.Power.LamUps(SegBeg:SegEnd, :); % check for a windmilling engine Windmill = Aircraft.Mission.History.SI.Power.Windmill(SegBeg:SegEnd, :); +% The two operational matrices describe the same mission point. Reject +% edge flows that disagree even when both matrix row sums are valid. +for ipnt = 1:length(PreqSnk) + SplitUps = PropulsionPkg.EvalSplit(OperUps, LamUps(ipnt, :)); + SplitDwn = PropulsionPkg.EvalSplit(OperDwn, LamDwn(ipnt, :)); + if (any(Windmill(ipnt, :))) + SplitDwn = RedistForWindmill(SplitDwn, Windmill(ipnt, :), nsrc, ncomp); + end + PropulsionPkg.CheckEdgePowerConsistency( ... + Arch, SplitUps, SplitDwn, EtaUps, EtaDwn, PreqSnk(ipnt)); +end + % aircraft weight Mass = Aircraft.Mission.History.SI.Weight.CurWeight(SegBeg:SegEnd); diff --git a/+PropulsionPkg/TestEdgePowerConsistency.m b/+PropulsionPkg/TestEdgePowerConsistency.m new file mode 100644 index 0000000..335cb5e --- /dev/null +++ b/+PropulsionPkg/TestEdgePowerConsistency.m @@ -0,0 +1,249 @@ +function [Success] = TestEdgePowerConsistency() +% +% [Success] = TestEdgePowerConsistency() +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Stress test edge-flow agreement between upstream and downstream power +% splits, including asymmetric branches, losses, and mission rejection. +% +% INPUTS: +% none +% +% OUTPUTS: +% Success - 1 when every consistency check passes, otherwise 0. +% size/type/units: 1-by-1 / int / [] +% + +%% TEST CASE SETUP %% +%%%%%%%%%%%%%%%%%%%%% + +Pass = false(9, 1); +[Arch, LamUps, LamDwn, EtaUps, EtaDwn] = makeParallelMotorGraph(0.5, 0.5); + +%% CONSISTENT EDGE FLOWS %% +%%%%%%%%%%%%%%%%%%%%%%%%%%% + +% Equal branches and asymmetric but matched branches both conserve power. +Pass(1) = CheckAccepted(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 100); +[Arch, LamUps, LamDwn, EtaUps, EtaDwn] = makeParallelMotorGraph(0.7, 0.7); +Pass(2) = CheckAccepted(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 100); + +% With losses, the source split is normalized by required input power, +% so it differs from the downstream split at the shared target. +TargetShare = 0.6; +MotorInput = TargetShare / 0.8; +OtherInput = 1 - TargetShare; +SourceShare = MotorInput / (MotorInput + OtherInput); +[Arch, LamUps, LamDwn, EtaUps, EtaDwn] = ... + makeParallelMotorGraph(SourceShare, TargetShare); +EtaUps(2, 4) = 0.8; +EtaDwn(4, 2) = 0.8; +Pass(3) = CheckAccepted(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 100); + +% A zero-demand point has no physical edge flow to contradict the splits. +Pass(4) = CheckAccepted(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 0); + +%% INCONSISTENT EDGE FLOWS %% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +% Both directions normalize to one, but they assign opposite branch power. +[Arch, LamUps, LamDwn, EtaUps, EtaDwn] = makeParallelMotorGraph(0.7, 0.3); +Pass(5) = CheckRejected(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 100); + +% Matching split fractions cannot compensate for unequal path efficiencies. +[Arch, LamUps, LamDwn, EtaUps, EtaDwn] = makeParallelMotorGraph(0.5, 0.5); +EtaUps(2, 4) = 0.8; +EtaDwn(4, 2) = 0.8; +Pass(6) = CheckRejected(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 100); + +% Even a correctly adjusted split must reject incompatible efficiencies. +[Arch, LamUps, LamDwn, EtaUps, EtaDwn] = ... + makeParallelMotorGraph(SourceShare, TargetShare); +EtaUps(2, 4) = 0.9; +EtaDwn(4, 2) = 0.8; +Pass(7) = CheckRejected(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 100); + +% A single edge that is active in only one direction must also fail. +[Arch, LamUps, LamDwn, EtaUps, EtaDwn] = makeParallelMotorGraph(1, 0); +Pass(8) = CheckRejected(Arch, LamUps, LamDwn, EtaUps, EtaDwn, 100); + +% Mission analysis must surface the same rejection, not only the helper. +Aircraft = makeMissionPoint(Arch, LamUps, LamDwn, EtaUps, EtaDwn); +Pass(9) = CheckMissionRejected(Aircraft); + +%% CHECK THE TEST RESULTS %% +%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +Success = all(Pass); +if (Success) + fprintf(1, "EdgePowerConsistency tests passed!\n"); +else + fprintf(1, "EdgePowerConsistency tests failed:\n"); + fprintf(1, " Test %d\n", find(~Pass)); +end + +end + +function [Arch, LamUps, LamDwn, EtaUps, EtaDwn] = makeParallelMotorGraph(SourceShare, TargetShare) +% +% [Arch, LamUps, LamDwn, EtaUps, EtaDwn] = makeParallelMotorGraph(SourceShare, TargetShare) +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Build battery -> two motors -> propeller -> thrust, with independent +% source-side and target-side split fractions. +% +% INPUTS: +% SourceShare - fraction of battery power routed to the first motor. +% size/type/units: 1-by-1 / double / [] +% TargetShare - fraction of propeller power supplied by first motor. +% size/type/units: 1-by-1 / double / [] +% +% OUTPUTS: +% Arch - source-to-target architecture matrix. +% size/type/units: 5-by-5 / double / [] +% LamUps - upstream operational matrix. +% size/type/units: 5-by-5 / double / [] +% LamDwn - downstream operational matrix. +% size/type/units: 5-by-5 / double / [] +% EtaUps - upstream efficiency matrix. +% size/type/units: 5-by-5 / double / [] +% EtaDwn - downstream efficiency matrix. +% size/type/units: 5-by-5 / double / [] +% + +Arch = zeros(5); +Arch(1, 2:3) = 1; +Arch(2:3, 4) = 1; +Arch(4, 5) = 1; +LamUps = zeros(5); +LamUps(1, 2:3) = [SourceShare, 1 - SourceShare]; +LamUps(2:3, 4) = 1; +LamUps(4, 5) = 1; +LamDwn = zeros(5); +LamDwn(2:3, 1) = 1; +LamDwn(4, 2:3) = [TargetShare, 1 - TargetShare]; +LamDwn(5, 4) = 1; +EtaUps = ones(5); +EtaDwn = ones(5); +end + +function Pass = CheckAccepted(Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower) +% +% Pass = CheckAccepted(Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower) +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Return true only if a physically consistent point is accepted. +% +% INPUTS: +% Arch, LamUps, LamDwn, EtaUps, EtaDwn - matrices defined above. +% SinkPower - final sink demand in watts. +% +% OUTPUTS: +% Pass - true if validation returns without an error. +% size/type/units: 1-by-1 / logical / [] +% + +Pass = true; +try + PropulsionPkg.CheckEdgePowerConsistency( ... + Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower); +catch + Pass = false; +end +end + +function Pass = CheckRejected(Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower) +% +% Pass = CheckRejected(Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower) +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Return true only when an inconsistent point produces the requested error. +% +% INPUTS: +% Arch, LamUps, LamDwn, EtaUps, EtaDwn - matrices defined above. +% SinkPower - final sink demand in watts. +% +% OUTPUTS: +% Pass - true if the specific inconsistency error is raised. +% size/type/units: 1-by-1 / logical / [] +% + +Pass = false; +try + PropulsionPkg.CheckEdgePowerConsistency( ... + Arch, LamUps, LamDwn, EtaUps, EtaDwn, SinkPower); +catch Error + Pass = strcmp(Error.identifier, "FAST:EdgePowerInconsistent") && ... + strcmp(Error.message, ... + "Edge power is not consistent. Your power splits are over-constrained."); +end +end + +function Aircraft = makeMissionPoint(Arch, LamUps, LamDwn, EtaUps, EtaDwn) +% +% Aircraft = makeMissionPoint(Arch, LamUps, LamDwn, EtaUps, EtaDwn) +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Supply the mission fields needed before PropAnalysis validates edge flow. +% +% INPUTS: +% Arch, LamUps, LamDwn, EtaUps, EtaDwn - matrices defined above. +% +% OUTPUTS: +% Aircraft - minimal propulsion and mission structure for rejection. +% size/type/units: 1-by-1 / struct / [] +% + +Aircraft.Specs.TLAR.Class = "Turboprop"; +Aircraft.Specs.Power.SpecEnergy.Fuel = 1; +Aircraft.Specs.Propulsion.PropArch.Type = "E"; +Aircraft.Specs.Propulsion.PropArch.SrcType = 0; +Aircraft.Specs.Propulsion.PropArch.TrnType = [0, 0, 2]; +Aircraft.Specs.Propulsion.PropArch.Arch = Arch; +Aircraft.Specs.Propulsion.PropArch.OperUps = @() LamUps; +Aircraft.Specs.Propulsion.PropArch.OperDwn = @() LamDwn; +Aircraft.Specs.Propulsion.PropArch.EtaUps = EtaUps; +Aircraft.Specs.Propulsion.PropArch.EtaDwn = EtaDwn; +Aircraft.Specs.Propulsion.PropArch.WhichProp = repmat({[]}, 1, 3); +Aircraft.Mission.Profile.SegsID = 1; +Aircraft.Mission.Profile.SegBeg = 1; +Aircraft.Mission.Profile.SegEnd = 1; +Aircraft.Mission.Profile.MissID = 1; +Aircraft.Mission.History.SI.Power.Req = 100; +Aircraft.Mission.History.SI.Power.Pav = zeros(1, 5); +Aircraft.Mission.History.SI.Power.LamUps = zeros(1, 0); +Aircraft.Mission.History.SI.Power.LamDwn = zeros(1, 0); +Aircraft.Mission.History.SI.Power.Windmill = false(1, 3); +end + +function Pass = CheckMissionRejected(Aircraft) +% +% Pass = CheckMissionRejected(Aircraft) +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Confirm that the mission entry point rejects mismatched edge power. +% +% INPUTS: +% Aircraft - minimal inconsistent mission point. +% size/type/units: 1-by-1 / struct / [] +% +% OUTPUTS: +% Pass - true if the specific inconsistency error is raised. +% size/type/units: 1-by-1 / logical / [] +% + +Pass = false; +try + PropulsionPkg.PropAnalysis(Aircraft); +catch Error + Pass = strcmp(Error.identifier, "FAST:EdgePowerInconsistent") && ... + strcmp(Error.message, ... + "Edge power is not consistent. Your power splits are over-constrained."); +end +end diff --git a/TestFAST.m b/TestFAST.m index 7d9a8f5..1e189fe 100644 --- a/TestFAST.m +++ b/TestFAST.m @@ -31,6 +31,7 @@ PropulsionPkg.TestCreatePropArch(); PropulsionPkg.TestPowerAvailable(); PropulsionPkg.TestEngineMotorRegressions(); +PropulsionPkg.TestEdgePowerConsistency(); %% TEST UNIT CONVERSION PACKAGE %% From f9cd1834b2bb1b8ed6fdd009fffab40ba2c6b4c6 Mon Sep 17 00:00:00 2001 From: Triet Ho Date: Wed, 16 Sep 2026 20:28:54 -0400 Subject: [PATCH 2/6] Fix multi-engine parallel connections --- +PropulsionPkg/PropArchConnections.m | 8 +++-- +PropulsionPkg/TestPropArchConnections.m | 39 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 +PropulsionPkg/TestPropArchConnections.m diff --git a/+PropulsionPkg/PropArchConnections.m b/+PropulsionPkg/PropArchConnections.m index ba79b22..5e534dc 100644 --- a/+PropulsionPkg/PropArchConnections.m +++ b/+PropulsionPkg/PropArchConnections.m @@ -2,7 +2,7 @@ % % [Aircraft] = PropArchConnections(Aircraft) % written by Paul Mokotoff, prmoko@umich.edu -% last updated: 17 jan 2025 +% last updated: 16 sep 2026 % % Given a propulsion architecture, identify any parallel electric motor / % engine connections. These connections are used to reduce the power @@ -77,7 +77,9 @@ if (~isempty(Driving) && ~isempty(Helping)) % list the electric motors - ParConns{Driving} = [ParConns{Driving}; Helping]; + for idrive = Driving(:)' + ParConns{idrive} = [ParConns{idrive}; Helping]; + end end end @@ -86,4 +88,4 @@ Aircraft.Specs.Propulsion.PropArch.ParConns = ParConns; -end \ No newline at end of file +end diff --git a/+PropulsionPkg/TestPropArchConnections.m b/+PropulsionPkg/TestPropArchConnections.m new file mode 100644 index 0000000..8b835b1 --- /dev/null +++ b/+PropulsionPkg/TestPropArchConnections.m @@ -0,0 +1,39 @@ +function [Success] = TestPropArchConnections() +% +% [Success] = TestPropArchConnections() +% written by Triet Ho +% last updated: 16 sep 2026 +% +% Each engine sharing a propeller with electric motors must retain the +% complete list of companion motors, including when Driving is nonscalar. +% +% OUTPUTS: +% Success - true when the one-, two-, three-, and nine-engine cases pass. +% size/type/units: 1-by-1 / logical / [] +% + +EngineCounts = [1, 2, 3, 9]; +Pass = false(size(EngineCounts)); +for icase = 1:length(EngineCounts) + neng = EngineCounts(icase); + nsrc = 1; + ntrn = neng + 3; + prop = nsrc + ntrn; + engines = nsrc + (1:neng); + motors = nsrc + neng + (1:2); + arch = zeros(nsrc + ntrn + 1); + arch([engines, motors], prop) = 1; + aircraft.Specs.Propulsion.PropArch.Arch = arch; + aircraft.Specs.Propulsion.PropArch.SrcType = 1; + aircraft.Specs.Propulsion.PropArch.TrnType = [ones(1, neng), 0, 0, 2]; + aircraft = PropulsionPkg.PropArchConnections(aircraft); + connections = aircraft.Specs.Propulsion.PropArch.ParConns; + Pass(icase) = all(cellfun(@(item) isequal(item, motors), connections(1:neng))) && ... + all(cellfun(@isempty, connections(neng + 1:end))); +end +Success = all(Pass); +if (~Success) + fprintf(1, "PropArchConnections failed for engine count(s): %s\n", ... + mat2str(EngineCounts(~Pass))); +end +end From df1825e7e04bab8aa99e7436ac963a109a08a8b6 Mon Sep 17 00:00:00 2001 From: Triet Ho Date: Fri, 18 Sep 2026 07:43:07 -0400 Subject: [PATCH 3/6] Test turboprop motor supplement accounting --- +PropulsionPkg/TestEngineModelSupplement.m | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 +PropulsionPkg/TestEngineModelSupplement.m diff --git a/+PropulsionPkg/TestEngineModelSupplement.m b/+PropulsionPkg/TestEngineModelSupplement.m new file mode 100644 index 0000000..c40c765 --- /dev/null +++ b/+PropulsionPkg/TestEngineModelSupplement.m @@ -0,0 +1,34 @@ +function [Success] = TestEngineModelSupplement() +% +% [Success] = TestEngineModelSupplement() +% written by Triet Ho +% +% Check that an electric-motor contribution is not credited twice when +% FAST has already propagated the split propeller demand to a turboprop. +% Turbofan thrust is derived from the whole fan demand and still needs its +% separately computed motor supplement. +% +% INPUTS: +% none +% +% OUTPUTS: +% Success - true when the three aircraft classes use the appropriate +% engine-model supplement. size/type/units: scalar/logical/[] +% + +MotorPower = [25, 75]; +Turboprop = PropulsionPkg.EngineModelSupplement("Turboprop", MotorPower); +Piston = PropulsionPkg.EngineModelSupplement("Piston", MotorPower); +Turbofan = PropulsionPkg.EngineModelSupplement("Turbofan", MotorPower); + +Success = isequal(Turboprop, zeros(size(MotorPower))) && ... + isequal(Piston, zeros(size(MotorPower))) && ... + isequal(Turbofan, MotorPower); + +if (Success) + fprintf(1, "EngineModelSupplement tests passed!\n"); +else + fprintf(1, "EngineModelSupplement tests failed!\n"); +end + +end From 0e122372117505c873cb1eb535bb1ec6c578aabc Mon Sep 17 00:00:00 2001 From: Triet Ho Date: Fri, 18 Sep 2026 07:43:54 -0400 Subject: [PATCH 4/6] Avoid double-counting motor power in turboprops --- +PropulsionPkg/EngineModelSupplement.m | 32 ++++++++++++++++++++++++++ +PropulsionPkg/PropAnalysis.m | 7 ++++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 +PropulsionPkg/EngineModelSupplement.m diff --git a/+PropulsionPkg/EngineModelSupplement.m b/+PropulsionPkg/EngineModelSupplement.m new file mode 100644 index 0000000..8cdcac8 --- /dev/null +++ b/+PropulsionPkg/EngineModelSupplement.m @@ -0,0 +1,32 @@ +function [ModelSupplement] = EngineModelSupplement(AircraftClass, MotorSupplement) +% +% [ModelSupplement] = EngineModelSupplement(AircraftClass, MotorSupplement) +% written by Triet Ho +% +% Select the electric supplement passed to the engine cycle model. FAST +% obtains turboprop/piston required shaft power from the split-aware graph +% power flow, so subtracting the motor contribution again would create a +% second credit and underpredict fuel burn. Turbofan thrust is based on +% the whole fan demand and still needs the explicit motor supplement. +% +% INPUTS: +% AircraftClass - FAST aircraft class. size/type/units: scalar/string/[] +% MotorSupplement - motor contribution at the shared target. +% size/type/units: numeric array/double/[W] +% +% OUTPUTS: +% ModelSupplement - electric input for the engine cycle model. +% size/type/units: same as MotorSupplement/double/[W] +% + +if (strcmpi(AircraftClass, "Turbofan")) + ModelSupplement = MotorSupplement; +elseif (strcmpi(AircraftClass, "Turboprop") || ... + strcmpi(AircraftClass, "Piston")) + ModelSupplement = zeros(size(MotorSupplement)); +else + error("FAST:InvalidAircraftClass", ... + "Unsupported aircraft class for engine supplement accounting."); +end + +end diff --git a/+PropulsionPkg/PropAnalysis.m b/+PropulsionPkg/PropAnalysis.m index 34f611d..d78c43a 100644 --- a/+PropulsionPkg/PropAnalysis.m +++ b/+PropulsionPkg/PropAnalysis.m @@ -572,7 +572,9 @@ OffParams.Thrust = TTemp(ipnt); % run the engine model - OffDesignEngine = EngFun(Aircraft, OffParams, Psupp(ipnt, icol), icol, ipnt); + OffDesignEngine = EngFun(Aircraft, OffParams, ... + PropulsionPkg.EngineModelSupplement(aclass, Psupp(ipnt, icol)), ... + icol, ipnt); elseif ((strcmpi(aclass, "Turboprop") == 1) || ... (strcmpi(aclass, "Piston" ) == 1) ) @@ -581,7 +583,8 @@ Aircraft.Specs.Propulsion.Engine.ReqPower = PTemp(ipnt); % run the engine model - OffDesignEngine = EngFun(Aircraft.Specs.Propulsion.Engine, Psupp(ipnt, icol)); + OffDesignEngine = EngFun(Aircraft.Specs.Propulsion.Engine, ... + PropulsionPkg.EngineModelSupplement(aclass, Psupp(ipnt, icol))); end From 053baa147a759e8870e7d4c594a4632cc4445aa7 Mon Sep 17 00:00:00 2001 From: Triet Ho Date: Fri, 18 Sep 2026 07:50:10 -0400 Subject: [PATCH 5/6] Test non-propulsive motor credit in FAST --- +PropulsionPkg/TestEngineMotorRegressions.m | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/+PropulsionPkg/TestEngineMotorRegressions.m b/+PropulsionPkg/TestEngineMotorRegressions.m index 052b05e..8ffd62f 100644 --- a/+PropulsionPkg/TestEngineMotorRegressions.m +++ b/+PropulsionPkg/TestEngineMotorRegressions.m @@ -12,7 +12,7 @@ % none % % OUTPUTS: -% Success - 1 when all 33 checks pass, otherwise 0. +% Success - 1 when all 34 checks pass, otherwise 0. % size/type/units: 1-by-1 / int / [] % @@ -20,7 +20,7 @@ %%%%%%%%%%%%%%%%%%%%% % Keep one result per case so a failure identifies the affected behavior. -Pass = false(33, 1); +Pass = false(34, 1); %% ENGINE TO PROPELLER CONNECTIONS %% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% @@ -165,6 +165,21 @@ Pass(caseIndex + 3) = CheckManyMixedDepthEngineConnections(engineCount); end +%% NON-PROPULSIVE PARALLEL TARGETS %% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +% A motor that powers an electric generator does not supplement the +% engine's fan. The engine siphons 1 W to that generator; only the motor's +% 50 W contribution at the separate fan earns a 40 W fan-adjusted credit. +architecture = zeros(4); +architecture(1:2, 3:4) = 1; +splits = zeros(4); +splits(3, 1:2) = [0.01, 0.99]; +splits(4, 1:2) = [0.5, 0.5]; +actual = PropulsionPkg.PowerSupplementCheck( ... + [51, 149, 100, 100], architecture, splits, ones(4), [1, 0, 3, 2], 0.8); +Pass(34) = CheckValue(actual(1), 39); + %% CHECK THE TEST RESULTS %% %%%%%%%%%%%%%%%%%%%%%%%%%%%% From e9faab1f1b6648ef755a288a3bdb4bf44ec29e2b Mon Sep 17 00:00:00 2001 From: Triet Ho Date: Fri, 18 Sep 2026 07:51:52 -0400 Subject: [PATCH 6/6] Credit motor assistance only at propulsors --- +PropulsionPkg/PowerSupplementCheck.m | 18 ++++++++++-------- +PropulsionPkg/TestEngineMotorRegressions.m | 14 +++++++------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/+PropulsionPkg/PowerSupplementCheck.m b/+PropulsionPkg/PowerSupplementCheck.m index 6e99fb3..a4b16b1 100644 --- a/+PropulsionPkg/PowerSupplementCheck.m +++ b/+PropulsionPkg/PowerSupplementCheck.m @@ -100,7 +100,15 @@ % find the gas-turbine engine being supplemented Driving = find((Arch(:, icomp) > 0)' & (TrnType == 1)); - % a shared target needs both an engine and a motor contribution + % Only a shared fan/propeller produces supplemental thrust. A + % generator or cable is an electrical load whose engine share is + % already represented by the downstream power split; crediting the + % motor there would subtract unrelated power from engine fuel flow. + if (TrnType(icomp) ~= 2) + continue; + end + + % a shared fan needs both an engine and a motor contribution Helping = find((Arch(:, icomp) > 0)' & (TrnType == 0)); if (isempty(Driving) || isempty(Helping)) continue; @@ -111,12 +119,6 @@ % motor input power here would apply the same split a second time. MotorPower = Preq(:, icomp) .* sum(Lambda(icomp, Helping)); - % Only a fan target needs the engine fan-efficiency conversion. - TargetEfficiency = 1; - if (TrnType(icomp) == 2) - TargetEfficiency = EtaFan; - end - % When engines share the target, allocate the motor credit in % proportion to their own contributions to that target. EngineShares = Lambda(icomp, Driving); @@ -124,7 +126,7 @@ EngineShares = ones(size(EngineShares)); end Psupp(:, Driving) = Psupp(:, Driving) + ... - MotorPower .* TargetEfficiency .* ... + MotorPower .* EtaFan .* ... (EngineShares ./ sum(EngineShares)); end diff --git a/+PropulsionPkg/TestEngineMotorRegressions.m b/+PropulsionPkg/TestEngineMotorRegressions.m index 8ffd62f..8e1b4af 100644 --- a/+PropulsionPkg/TestEngineMotorRegressions.m +++ b/+PropulsionPkg/TestEngineMotorRegressions.m @@ -57,15 +57,15 @@ % fan target applies fan efficiency to the motor contribution Pass(7) = CheckValue(parallelSupplement([1, 0, 2], [0, 10, 0], 0.8), 8); -% generator and cable targets do not incur a fan-efficiency loss -Pass(8) = CheckValue(parallelSupplement([1, 0, 3], [0, 10, 0], 0.8), 10); -Pass(9) = CheckValue(parallelSupplement([1, 0, 4], [0, 10, 0], 0.65), 10); +% generator and cable targets are loads, not supplemental fan thrust +Pass(8) = CheckValue(parallelSupplement([1, 0, 3], [0, 10, 0], 0.8), 0); +Pass(9) = CheckValue(parallelSupplement([1, 0, 4], [0, 10, 0], 0.65), 0); -% contributions from two motors must both be counted at a shared target -Pass(10) = CheckValue(parallelSupplement([1, 0, 0, 3], [0, 7, 11, 0], 0.8), 18); +% neither motor creates fan thrust when both feed one generator +Pass(10) = CheckValue(parallelSupplement([1, 0, 0, 3], [0, 7, 11, 0], 0.8), 0); -% unity fan efficiency leaves the motor contribution unchanged -Pass(11) = CheckValue(parallelSupplement([1, 0, 3], [0, 10, 0], 1), 10); +% unity fan efficiency cannot turn a generator load into fan thrust +Pass(11) = CheckValue(parallelSupplement([1, 0, 3], [0, 10, 0], 1), 0); % only one quarter of the first fan's demand comes from the motor architecture = zeros(4);