From 9d8910f0ccd1915b39cd73e9989512a3b3bcca72 Mon Sep 17 00:00:00 2001 From: siimav <1120038+siimav@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:08:41 +0300 Subject: [PATCH 1/4] Run boiloff in the background through Kerbalism --- RealFuels/Resources/RealTankTypes.cfg | 63 ----- Source/RealFuels.csproj | 3 + Source/Tanks/FuelTank.cs | 7 - Source/Tanks/ModuleFuelTanksRF.cs | 306 ++++++++++++++++++++++--- Source/Utilities/KerbalismInterface.cs | 74 ++++++ Source/Utilities/ReflectionHelpers.cs | 82 +++++++ 6 files changed, 438 insertions(+), 97 deletions(-) create mode 100644 Source/Utilities/KerbalismInterface.cs create mode 100644 Source/Utilities/ReflectionHelpers.cs diff --git a/RealFuels/Resources/RealTankTypes.cfg b/RealFuels/Resources/RealTankTypes.cfg index 41d3b480..6c295480 100644 --- a/RealFuels/Resources/RealTankTypes.cfg +++ b/RealFuels/Resources/RealTankTypes.cfg @@ -205,7 +205,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 180.65 - loss_rate = 0.00000000023 note = (lacks insulation) } TANK @@ -226,7 +225,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 184.65 - loss_rate = 0.00000000025 note = (lacks insulation) } TANK @@ -238,7 +236,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 169.45 - loss_rate = 0.00000000035 note = (lacks insulation) } TANK @@ -250,7 +247,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 128.4 - loss_rate = 0.000000001 note = (lacks insulation) } TANK @@ -262,7 +258,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 85.04 - loss_rate = 0.0000000055 note = (lacks insulation) } TANK @@ -274,7 +269,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 200.15 - loss_rate = 0.00000000017 note = (lacks insulation) } TANK @@ -331,7 +325,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -343,7 +336,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -355,7 +347,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -706,7 +697,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 180.65 - loss_rate = 0 note = (has insulation) } TANK @@ -727,7 +717,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 184.65 - loss_rate = 0 note = (has insulation) } TANK @@ -739,7 +728,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 169.45 - loss_rate = 0 note = (has insulation) } TANK @@ -751,7 +739,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 128.4 - loss_rate = 0.000000000016 note = (has insulation) } TANK @@ -763,7 +750,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 85.04 - loss_rate = 0.000000000088 note = (has insulation) } TANK @@ -775,7 +761,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 200.15 - loss_rate = 0 note = (has insulation) } TANK @@ -832,7 +817,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation) } TANK @@ -844,7 +828,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation) } TANK @@ -856,7 +839,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation) } TANK @@ -1250,7 +1232,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 180.65 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -1272,7 +1253,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 184.65 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -1284,7 +1264,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 169.45 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -1296,7 +1275,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 128.4 - loss_rate = 0.000000000016 note = (has insulation, pressurized) } TANK @@ -1308,7 +1286,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 85.04 - loss_rate = 0.000000000088 note = (has insulation, pressurized) } TANK @@ -1320,7 +1297,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 200.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -1382,7 +1358,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -1394,7 +1369,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -1406,7 +1380,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -1927,7 +1900,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 180.65 - loss_rate = 0.00000000023 note = (lacks insulation) } TANK @@ -1948,7 +1920,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 184.65 - loss_rate = 0.00000000025 note = (lacks insulation) } TANK @@ -1960,7 +1931,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 169.45 - loss_rate = 0.00000000035 note = (lacks insulation) } TANK @@ -1972,7 +1942,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 128.4 - loss_rate = 0.000000001 note = (lacks insulation) } TANK @@ -1984,7 +1953,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 85.04 - loss_rate = 0.0000000055 note = (lacks insulation) } TANK @@ -1996,7 +1964,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 200.15 - loss_rate = 0.00000000017 note = (lacks insulation) } TANK @@ -2053,7 +2020,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -2065,7 +2031,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -2077,7 +2042,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -2478,7 +2442,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 180.65 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -2500,7 +2463,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 184.65 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -2512,7 +2474,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 169.45 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -2524,7 +2485,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 128.4 - loss_rate = 0.000000000016 note = (has insulation, pressurized) } TANK @@ -2536,7 +2496,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 85.04 - loss_rate = 0.000000000088 note = (has insulation, pressurized) isDewar = true } @@ -2549,7 +2508,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 200.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -2611,7 +2569,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -2623,7 +2580,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -2635,7 +2591,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation, pressurized) } TANK @@ -3156,7 +3111,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 180.65 - loss_rate = 0.00000000023 note = (lacks insulation) } TANK @@ -3177,7 +3131,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 184.65 - loss_rate = 0.00000000025 note = (lacks insulation) } TANK @@ -3189,7 +3142,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 169.45 - loss_rate = 0.00000000035 note = (lacks insulation) } TANK @@ -3201,7 +3153,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 128.4 - loss_rate = 0.000000001 note = (lacks insulation) } TANK @@ -3213,7 +3164,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 85.04 - loss_rate = 0.0000000055 note = (lacks insulation) } TANK @@ -3225,7 +3175,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 200.15 - loss_rate = 0.00000000017 note = (lacks insulation) } TANK @@ -3282,7 +3231,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -3294,7 +3242,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -3306,7 +3253,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0.0000000001 note = (lacks insulation) } TANK @@ -3657,7 +3603,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 180.65 - loss_rate = 0 note = (has insulation) } TANK @@ -3678,7 +3623,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 184.65 - loss_rate = 0 note = (has insulation) } TANK @@ -3690,7 +3634,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 169.45 - loss_rate = 0 note = (has insulation) } TANK @@ -3702,7 +3645,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 128.4 - loss_rate = 0.000000000016 note = (has insulation) } TANK @@ -3714,7 +3656,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 85.04 - loss_rate = 0.000000000088 note = (has insulation) } TANK @@ -3726,7 +3667,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 200.15 - loss_rate = 0 note = (has insulation) } TANK @@ -3783,7 +3723,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation) } TANK @@ -3795,7 +3734,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation) } TANK @@ -3807,7 +3745,6 @@ TANK_DEFINITION amount = 0.0 maxAmount = 0.0 temperature = 216.15 - loss_rate = 0 note = (has insulation) } TANK diff --git a/Source/RealFuels.csproj b/Source/RealFuels.csproj index 47a1f248..bbe38625 100644 --- a/Source/RealFuels.csproj +++ b/Source/RealFuels.csproj @@ -9,6 +9,7 @@ RealFuels v4.8 + 7.3 10.8.0 ..\Build\RealFuels\obj @@ -120,6 +121,7 @@ + @@ -134,6 +136,7 @@ + diff --git a/Source/Tanks/FuelTank.cs b/Source/Tanks/FuelTank.cs index 3450b31e..d6423741 100644 --- a/Source/Tanks/FuelTank.cs +++ b/Source/Tanks/FuelTank.cs @@ -16,9 +16,6 @@ namespace RealFuels.Tanks // of tank installed for this resource type. Tons per // volume unit. // temperature the part temperature at which this tank's contents start boiling - // loss_rate How quickly this resource type bleeds out of the tank. - // (TODO: instead of this unrealistic static loss_rate, all - // resources should have vsp (heat of vaporization) added and optionally conduction) // public class FuelTank : IConfigNode @@ -37,10 +34,6 @@ public class FuelTank : IConfigNode public float mass = 0.0f; [Persistent] public float cost = 0.0f; - // TODO Retaining for fallback purposes but should be deprecated - [Persistent] - public double loss_rate = 0.0; - public double vsp; public double resourceConductivity = 10; diff --git a/Source/Tanks/ModuleFuelTanksRF.cs b/Source/Tanks/ModuleFuelTanksRF.cs index ffa9f6c9..0d867b38 100644 --- a/Source/Tanks/ModuleFuelTanksRF.cs +++ b/Source/Tanks/ModuleFuelTanksRF.cs @@ -1,6 +1,7 @@ using KSP.Localization; using System; using System.Collections.Generic; +using System.Globalization; using UnityEngine; using UnityEngine.Profiling; @@ -20,7 +21,8 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr public float _numberOfAddedMLILayers = 0; // This is the number of layers added by the player. public int numberOfAddedMLILayers => (int)_numberOfAddedMLILayers; - public int totalMLILayers => numberOfMLILayers + numberOfAddedMLILayers; + [KSPField(isPersistant = true)] + public int totalMLILayers = 0; [KSPField(isPersistant = true)] protected double totalTankArea; @@ -39,6 +41,19 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr private double cooling = 0; + // Thermal data captured while loaded, used for processing boiloff BackgroundUpdate on unloaded vessels. + // Format per entry: "resourceName,coldTempK,conductInternalWPerK,dewarAreaM2" + // coldTempK — boiling point of the propellant (K) + // conductInternalWPerK — part-interior→liquid thermal conductance in W/K (0 for Dewar tanks) + // dewarAreaM2 — tank area for Stefan-Boltzmann Dewar formula (−1 for non-Dewar tanks) + [KSPField(isPersistant = true)] + public string bgBoiloffData = ""; + + // UT of the last Kerbalism BackgroundUpdate tick; used to avoid double-applying boiloff + // when the vessel loads and SetAnalyticTemperature catches up for the unloaded period. + [KSPField(isPersistant = true)] + public double bgBoiloffLastUpdate = 0d; + [KSPField] public int maxMLILayers = 10; @@ -96,6 +111,40 @@ partial void OnAwakeRF() } } + partial void OnSaveRF(ConfigNode _) + { + if (!HighLogic.LoadedSceneIsFlight || cryoTanks.Count == 0) + return; + + var bgEntries = new Dictionary(); + foreach (var tank in cryoTanks) + { + if (tank.amount <= 0) continue; + + if (tank.vsp > 0) + { + double conductInternalWPerK, dewarAreaM2; + if (tank.isDewar) + { + conductInternalWPerK = 0; + dewarAreaM2 = tank.totalArea; + } + else + { + double wallF = tank.wallConduction > 0 ? tank.wallThickness / tank.wallConduction : 0; + double insulF = tank.insulationConduction > 0 ? tank.insulationThickness / tank.insulationConduction : 0; + double resF = tank.resourceConductivity > 0 ? 0.01 / tank.resourceConductivity : 0; + conductInternalWPerK = tank.totalArea / Math.Max(double.Epsilon, wallF + insulF + resF); + dewarAreaM2 = -1; + } + bgEntries[tank.name] = string.Format(CultureInfo.InvariantCulture, "{0},{1:R},{2:R},{3:R}", + tank.name, tank.temperature, conductInternalWPerK, dewarAreaM2); + } + } + + bgBoiloffData = bgEntries.Count > 0 ? string.Join(";", bgEntries.Values) : ""; + } + partial void OnStartRF(StartState _) { if (HighLogic.LoadedSceneIsFlight) @@ -103,7 +152,7 @@ partial void OnStartRF(StartState _) foreach (var tank in tanksDict.Values) { - if (tank.maxAmount > 0 && (tank.vsp > 0 || tank.loss_rate > 0)) + if (tank.maxAmount > 0 && tank.vsp > 0) cryoTanks.Add(tank); } CalculateTankArea(); @@ -112,9 +161,11 @@ partial void OnStartRF(StartState _) { Fields[nameof(_numberOfAddedMLILayers)].guiActiveEditor = maxMLILayers > 0; _numberOfAddedMLILayers = Mathf.Clamp(_numberOfAddedMLILayers, 0, maxMLILayers); + totalMLILayers = numberOfMLILayers + numberOfAddedMLILayers; ((UI_FloatRange)Fields[nameof(_numberOfAddedMLILayers)].uiControlEditor).maxValue = maxMLILayers; Fields[nameof(_numberOfAddedMLILayers)].uiControlEditor.onFieldChanged = delegate (BaseField field, object value) { + totalMLILayers = numberOfMLILayers + numberOfAddedMLILayers; massDirty = true; CalculateMass(); }; @@ -353,17 +404,6 @@ private void CalculateTankBoiloff(double deltaTime, bool analyticalMode = false, } } } - else if (tankAmount > 0 && tank.loss_rate > 0) - { - double deltaTemp = part.temperature - tank.temperature; - if (deltaTemp > 0) - { - double lossAmount = tank.maxAmount * tank.loss_rate * deltaTemp * deltaTime; - lossAmount = Math.Min(lossAmount, tankAmount); - tank.resource.amount -= lossAmount; - boiloffMassT += lossAmount * tank.density; - } - } } } Profiler.EndSample(); @@ -385,6 +425,7 @@ partial void UpdateTankTypeRF(TankDefinition def) _numberOfAddedMLILayers = Mathf.Clamp(_numberOfAddedMLILayers, 0, maxMLILayers); ((UI_FloatRange)Fields[nameof(_numberOfAddedMLILayers)].uiControlEditor).maxValue = maxMLILayers; } + totalMLILayers = numberOfMLILayers + numberOfAddedMLILayers; InitUtilization(); @@ -482,7 +523,22 @@ public void SetAnalyticTemperature(FlightIntegrator fi, double analyticTemp, dou // Alternatively, just adjust the analytic output using the boiloff calculation anyway. if (fi.timeSinceLastUpdate < double.MaxValue) - CalculateTankBoiloff(fi.timeSinceLastUpdate, fi.isAnalytical, intScalar, skinScalar); + { + double remainingTime; + if (bgBoiloffLastUpdate > 0d) + { + remainingTime = Math.Max(0d, Planetarium.GetUniversalTime() - bgBoiloffLastUpdate); + analyticInternalTemp = ComputeMLIEquilibriumTemp(analyticSkinTemp); + bgBoiloffLastUpdate = 0d; + } + else + { + remainingTime = fi.timeSinceLastUpdate; + } + + if (remainingTime > 0d) + CalculateTankBoiloff(remainingTime, fi.isAnalytical, intScalar, skinScalar); + } else if (CalculateLowestTankTemperature()) { // Vessel is freshly spawned and has cryogenic tanks, set temperatures appropriately @@ -554,36 +610,231 @@ void DebugLog(string msg) /// Default hot and cold values of 300 / 70. Can be called in real time substituting skin temp and internal temp for hot and cold. /// private double GetMLITransferRate(double outerTemperature = 300, double innerTemperature = 70) - { - // - double QrCoefficient = 0.0000000004944; // typical MLI radiation flux coefficient - double QcCoefficient = 0.0000000895; // typical MLI conductive flux coefficient. Possible tech upgrade target based on spacing mechanism between layers? - //double QvCoefficient = 3.65; // 14.600; // 14600; // not even sure how this is right: convective contribution will be MURDEROUS. - double emissivity = 0.03; // typical reflective mylar emissivity...? - double layerDensity = 10.055; //14.99813f; // 8.51f; // layer density (layers/cm) + => GetMLITransferRate(outerTemperature, innerTemperature, totalMLILayers, vessel.staticPressurekPa); - double radiation = (QrCoefficient * emissivity * (Math.Pow(outerTemperature, 4.67) - Math.Pow(innerTemperature, 4.67))) / totalMLILayers; - double conduction = ((QcCoefficient * Math.Pow(layerDensity, 2.63) * ((outerTemperature + innerTemperature) / 2)) / (totalMLILayers + 1)) * (outerTemperature - innerTemperature); - double convection = RFSettings.Instance.QvCoefficient * ((vessel.staticPressurekPa * 7.500616851) * (Math.Pow(outerTemperature, 0.52) - Math.Pow(innerTemperature, 0.52))) / totalMLILayers; - return radiation + conduction + convection; + private static double GetMLITransferRate(double outerTemp, double innerTemp, int mliLayers, double pressureKPa = 0) + { + const double QrCoefficient = 0.0000000004944; // typical MLI radiation flux coefficient + const double QcCoefficient = 0.0000000895; // typical MLI conductive flux coefficient + const double emissivity = 0.03; // typical reflective mylar emissivity + const double layerDensity = 10.055; // layer density (layers/cm) + + double radiation = QrCoefficient * emissivity * (Math.Pow(outerTemp, 4.67) - Math.Pow(innerTemp, 4.67)) / mliLayers; + double conduction = QcCoefficient * Math.Pow(layerDensity, 2.63) * ((outerTemp + innerTemp) / 2) / (mliLayers + 1) * (outerTemp - innerTemp); + double result = radiation + conduction; + if (pressureKPa > 0) + result += RFSettings.Instance.QvCoefficient * (pressureKPa * 7.500616851) * (Math.Pow(outerTemp, 0.52) - Math.Pow(innerTemp, 0.52)) / mliLayers; + return result; } /// /// Transfer rate through Dewar walls /// This is simplified down to basic radiation formula using corrected emissivity values for concentric walls for sake of performance /// - private double GetDewarTransferRate(double hot, double cold, double area) + private static double GetDewarTransferRate(double hot, double cold, double area) { // TODO Just radiation now; need to calculate conduction through piping/lid, etc double emissivity = 0.005074871897; // corrected and rounded value for concentric surfaces, actual emissivity of each surface is assumed to be 0.01 for silvered or aluminized coating return PhysicsGlobals.StefanBoltzmanConstant * emissivity * area * (Math.Pow(hot,4) - Math.Pow(cold,4)); } + /// + /// Returns the steady-state interior temperature for MLI-insulated cryo tanks at the given skin temperature, + /// i.e. the point where heat conducted inward through the MLI blanket equals heat absorbed by boiloff. + /// + /// + /// + private double ComputeMLIEquilibriumTemp(double skinTemp) + { + if (totalMLILayers <= 0 || totalTankArea <= 0) + return skinTemp; + + var tankParams = new List<(double conductW, double coldK)>(cryoTanks.Count); + var dewarParams = new List<(double area, double coldK)>(); + foreach (var tank in cryoTanks) + { + if (tank.vsp <= 0) continue; + + if (tank.isDewar) + { + dewarParams.Add((tank.totalArea, tank.temperature)); + } + else + { + double wallF = tank.wallConduction > 0 ? tank.wallThickness / tank.wallConduction : 0; + double insulF = tank.insulationConduction > 0 ? tank.insulationThickness / tank.insulationConduction : 0; + double resF = tank.resourceConductivity > 0 ? 0.01 / tank.resourceConductivity : 0; + double conductW = tank.totalArea / Math.Max(double.Epsilon, wallF + insulF + resF); + tankParams.Add((conductW, tank.temperature)); + } + } + + return tankParams.Count > 0 || dewarParams.Count > 0 + ? SolveMLIEquilibrium(skinTemp, totalTankArea, totalMLILayers, tankParams, dewarParams) + : skinTemp; + } + + /// + /// Finds the shared part-interior equilibrium temperature given skin temperature and all cryo heat sinks. + /// Solves: GetMLITransferRate(skinTemp, T) × tankAreaM2 = Σ conductW_i × max(0, T − coldK_i) via bisection. + /// Left side is monotonically decreasing in T; right side increasing → unique root. + /// + private static double SolveMLIEquilibrium( + double skinTemp, double tankAreaM2, int mliLayers, + List<(double conductW, double coldK)> tanks, + List<(double area, double coldK)> dewarTanks) + { + double lo = double.MaxValue; + foreach (var t in tanks) + { + if (t.coldK < lo) lo = t.coldK; + } + + foreach (var d in dewarTanks) + { + if (d.coldK < lo) lo = d.coldK; + } + + double hi = skinTemp; + if (lo >= hi) return hi; + + const double tolerance = 1e-3; // 1 mK — well below any physical significance + while (hi - lo > tolerance) + { + double mid = (lo + hi) * 0.5; + double mliFlux = GetMLITransferRate(skinTemp, mid, mliLayers) * tankAreaM2; // TODO: currently assumes pressureKPa is always 0 for unloaded vessels (space vacuum, no convective contribution). + double internalFlux = 0; + foreach (var t in tanks) + { + internalFlux += t.conductW * Math.Max(0, mid - t.coldK); + } + + foreach (var d in dewarTanks) + { + if (mid > d.coldK) + internalFlux += GetDewarTransferRate(mid, d.coldK, d.area); + } + + if (mliFlux > internalFlux) lo = mid; + else hi = mid; + } + return (lo + hi) * 0.5; + } + #endregion #region Kerbalism + + /// + /// Called by Kerbalism for unloaded (background) vessels via reflection. + /// Solves the MLI thermal equilibrium jointly for all non-Dewar cryo propellants in the part, + /// using Kerbalism's geometry+orientation-corrected VesselTemperature as the skin hot-side. + /// Solving jointly is essential: in a LH2+LOX tank, LH2's heat sink keeps T_interior below + /// LOX's boiling point, correctly suppressing LOX boiloff without any special-casing. + /// Falls back to the stored rate per-tank when geometry data is unavailable. + /// + public static string BackgroundUpdate( + Vessel vessel, + ProtoPartSnapshot proto_part, + ProtoPartModuleSnapshot proto_module, + PartModule partModule, + Part part, + Dictionary availableResources, + List> resourceChangeRequest, + double elapsed_s) + { + string data = proto_module.moduleValues.GetValue(nameof(bgBoiloffData)); + if (string.IsNullOrEmpty(data)) return string.Empty; + + bool hasGeometry = KerbalismInterface.TryGetThermalData(vessel, out double vesselTemp, out _); + + // Read MLI geometry needed for the equilibrium solver. + int.TryParse(proto_module.moduleValues.GetValue(nameof(totalMLILayers)), out int mliLayers); + double.TryParse(proto_module.moduleValues.GetValue(nameof(totalTankArea)), + NumberStyles.Float, CultureInfo.InvariantCulture, out double tankAreaM2); + + // Parse all entries. Separate Dewar tanks (handled individually) from vsp tanks + // (handled via joint MLI equilibrium). + var vspTanks = new List<(string name, double coldK, double conductW, PartResourceDefinition resDef)>(); + var dewarTanks = new List<(string name, double coldK, double dewarArea, PartResourceDefinition resDef)>(); + + foreach (string entry in data.Split(';')) + { + if (string.IsNullOrEmpty(entry)) continue; + string[] f = entry.Split(','); + if (f.Length != 4) continue; + string resourceName = f[0]; + if (!double.TryParse(f[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double coldK)) continue; + if (!double.TryParse(f[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double conductW)) continue; + if (!double.TryParse(f[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double dewarArea)) continue; + + PartResourceDefinition resDef = PartResourceLibrary.Instance.GetDefinition(resourceName); + if (resDef == null || resDef.density <= 0d) continue; + + if (dewarArea >= 0) + dewarTanks.Add((resourceName, coldK, dewarArea, resDef)); + else if (conductW > 0 && MFSSettings.resourceVsps.ContainsKey(resourceName)) + vspTanks.Add((resourceName, coldK, conductW, resDef)); + } + + bool anyRequest = false; + + // Solve the shared part-interior temperature, accounting for MLI and all cryo heat sinks. + // Requires Kerbalism VesselTemperature; boiloff is skipped entirely if geometry data is unavailable. + if (hasGeometry) + { + double interiorTemp; + if (mliLayers > 0 && tankAreaM2 > 0 && (vspTanks.Count > 0 || dewarTanks.Count > 0)) + { + var tankParams = new List<(double conductW, double coldK)>(vspTanks.Count); + foreach (var t in vspTanks) + { + tankParams.Add((t.conductW, t.coldK)); + } + + var dewarParams = new List<(double area, double coldK)>(dewarTanks.Count); + foreach (var d in dewarTanks) + { + dewarParams.Add((d.dewarArea, d.coldK)); + } + + interiorTemp = SolveMLIEquilibrium(vesselTemp, tankAreaM2, mliLayers, tankParams, dewarParams); + } + else + { + interiorTemp = vesselTemp; // no MLI: interior equilibrates to skin temperature + } + + foreach (var (name, coldK, conductW, resDef) in vspTanks) + { + if (!MFSSettings.resourceVsps.TryGetValue(name, out double vsp) || vsp <= 0) continue; + double deltaTemp = interiorTemp - coldK; + if (deltaTemp <= 0) continue; + double rateKgS = conductW * deltaTemp * 0.001 / vsp; + if (rateKgS <= 0) continue; + resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / resDef.density)); + anyRequest = true; + } + + foreach (var (name, coldK, dewarArea, resDef) in dewarTanks) + { + if (interiorTemp <= coldK || !MFSSettings.resourceVsps.TryGetValue(name, out double vsp) || vsp <= 0) continue; + double Q_kW = GetDewarTransferRate(interiorTemp, coldK, dewarArea) * 0.001; + double rateKgS = Math.Max(0, Q_kW / vsp); + if (rateKgS <= 0) continue; + resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / resDef.density)); + anyRequest = true; + } + } + + proto_module.moduleValues.SetValue("bgBoiloffLastUpdate", + Planetarium.GetUniversalTime().ToString(CultureInfo.InvariantCulture)); + + return anyRequest ? Localizer.GetStringByTag("#RF_FuelTankRF_kerbalismtips") : string.Empty; + } + /// - /// Called by Kerbalism every frame. Uses their resource system when Kerbalism is installed. + /// Called by Kerbalism every frame for loaded vessels. Uses their resource system when Kerbalism is installed. /// public virtual string ResourceUpdate(Dictionary availableResources, List> resourceChangeRequest) { @@ -601,6 +852,7 @@ public virtual string ResourceUpdate(Dictionary availableResourc return Localizer.GetStringByTag("#RF_FuelTankRF_kerbalismtips"); // "boiloff product" } + #endregion #region Tank Dimensions diff --git a/Source/Utilities/KerbalismInterface.cs b/Source/Utilities/KerbalismInterface.cs new file mode 100644 index 00000000..a2f8d1f3 --- /dev/null +++ b/Source/Utilities/KerbalismInterface.cs @@ -0,0 +1,74 @@ +using System; +using System.Reflection; +using UnityEngine; + +namespace RealFuels +{ + public static class KerbalismInterface + { + private static bool _initialized; + private static Func _getVesselData; + private static Func _getVesselTemperature; + private static Func _getVesselSurfaceArea; + + public static bool TryGetThermalData(Vessel vessel, out double vesselTemp, out double surfaceArea) + { + vesselTemp = 0; + surfaceArea = -1; + + if (!_initialized) + Initialize(); + + if (_getVesselData == null || _getVesselTemperature == null || _getVesselSurfaceArea == null) + return false; + + try + { + object vd = _getVesselData(vessel); + if (vd == null) return false; + vesselTemp = _getVesselTemperature(vd); + surfaceArea = _getVesselSurfaceArea(vd); + return surfaceArea > 0; + } + catch + { + return false; + } + } + + private static void Initialize() + { + _initialized = true; + try + { + Assembly kbAsm = null; + foreach (var a in AppDomain.CurrentDomain.GetAssemblies()) + { + if (string.Equals(new AssemblyName(a.FullName).Name, "Kerbalism", StringComparison.OrdinalIgnoreCase)) + { + kbAsm = a; + break; + } + } + if (kbAsm == null) return; + + Type dbType = kbAsm.GetType("KERBALISM.DB"); + MethodInfo vesselDataMethod = dbType?.GetMethod("KerbalismData", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(Vessel) }, null); + Type vdType = kbAsm.GetType("KERBALISM.VesselData"); + PropertyInfo tempProp = vdType?.GetProperty("VesselTemperature"); + PropertyInfo areaProp = vdType?.GetProperty("VesselSurfaceArea"); + + if (vesselDataMethod != null) + _getVesselData = ReflectionHelpers.BuildStaticMethodDelegate(vesselDataMethod); + if (tempProp != null) + _getVesselTemperature = ReflectionHelpers.BuildPropertyGetter(tempProp); + if (areaProp != null) + _getVesselSurfaceArea = ReflectionHelpers.BuildPropertyGetter(areaProp); + } + catch (Exception ex) + { + Debug.LogWarning($"[RF] KerbalismInterface reflection init failed: {ex}"); + } + } + } +} diff --git a/Source/Utilities/ReflectionHelpers.cs b/Source/Utilities/ReflectionHelpers.cs new file mode 100644 index 00000000..8cdacabd --- /dev/null +++ b/Source/Utilities/ReflectionHelpers.cs @@ -0,0 +1,82 @@ +using System; +using System.Reflection; +using System.Reflection.Emit; + +namespace RealFuels +{ + public static class ReflectionHelpers + { + /// + /// Create a getter for an instance or static field. Only works with fields declared in a class (won't work for struct fields).
+ ///
+ /// The field type + /// The field info + /// An func delegate where the argument is the class instance (or null for a static field) and the return value is the field value + public static Func CreateFieldGetter(FieldInfo field) + { + string methodName = field.ReflectedType.FullName + ".get_" + field.Name; + DynamicMethod setterMethod = new DynamicMethod(methodName, typeof(T), new Type[1] { typeof(object) }, true); + ILGenerator gen = setterMethod.GetILGenerator(); + if (field.IsStatic) + { + gen.Emit(OpCodes.Ldsfld, field); + } + else + { + gen.Emit(OpCodes.Ldarg_0); + gen.Emit(OpCodes.Ldfld, field); + } + gen.Emit(OpCodes.Ret); + return (Func)setterMethod.CreateDelegate(typeof(Func)); + } + + /// + /// Create a setter for an instance or static field. Only works with fields declared in a class (won't work for struct fields). + /// + /// The field type + /// The field info + /// An action delegate where the first argument is the class instance (or null for a static field) and the second argument is the new value + public static Action CreateFieldSetter(FieldInfo field) + { + string methodName = field.ReflectedType.FullName + ".set_" + field.Name; + DynamicMethod setterMethod = new DynamicMethod(methodName, null, new Type[2] { typeof(object), typeof(T) }, true); + ILGenerator gen = setterMethod.GetILGenerator(); + if (field.IsStatic) + { + gen.Emit(OpCodes.Ldarg_1); + gen.Emit(OpCodes.Stsfld, field); + } + else + { + gen.Emit(OpCodes.Ldarg_0); + gen.Emit(OpCodes.Ldarg_1); + gen.Emit(OpCodes.Stfld, field); + } + gen.Emit(OpCodes.Ret); + return (Action)setterMethod.CreateDelegate(typeof(Action)); + } + + public static Func BuildPropertyGetter(PropertyInfo prop) + { + MethodInfo getter = prop.GetGetMethod(nonPublic: true); + var dm = new DynamicMethod(prop.DeclaringType.FullName + ".get_" + prop.Name, + typeof(T), new[] { typeof(object) }, true); + ILGenerator il = dm.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Callvirt, getter); + il.Emit(OpCodes.Ret); + return (Func)dm.CreateDelegate(typeof(Func)); + } + + public static Func BuildStaticMethodDelegate(MethodInfo method) + { + var dm = new DynamicMethod(method.DeclaringType.FullName + "." + method.Name, + typeof(object), new[] { typeof(Vessel) }, true); + ILGenerator il = dm.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, method); + il.Emit(OpCodes.Ret); + return (Func)dm.CreateDelegate(typeof(Func)); + } + } +} From 0c11e7f65bc7f9273730adfe9c8daa7656fa2c39 Mon Sep 17 00:00:00 2001 From: siimav <1120038+siimav@users.noreply.github.com> Date: Tue, 12 May 2026 14:11:33 +0300 Subject: [PATCH 2/4] Add cache, reduce allocations --- Source/RealFuels.csproj | 1 + Source/Tanks/BgBoiloffCache.cs | 33 +++++++ Source/Tanks/ModuleFuelTanksRF.cs | 152 +++++++++++++++++------------- 3 files changed, 121 insertions(+), 65 deletions(-) create mode 100644 Source/Tanks/BgBoiloffCache.cs diff --git a/Source/RealFuels.csproj b/Source/RealFuels.csproj index bbe38625..2f64b63a 100644 --- a/Source/RealFuels.csproj +++ b/Source/RealFuels.csproj @@ -116,6 +116,7 @@ + diff --git a/Source/Tanks/BgBoiloffCache.cs b/Source/Tanks/BgBoiloffCache.cs new file mode 100644 index 00000000..3ecc8b64 --- /dev/null +++ b/Source/Tanks/BgBoiloffCache.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RealFuels.Tanks +{ + internal sealed class BgBoiloffCache + { + internal readonly string DataVersion; + internal readonly int MliLayers; + internal readonly double TankAreaM2; + // Arrays are paired up: VspParams[i] and VspInfo[i]; DewarParams[i] and DewarInfo[i] + internal readonly (double conductW, double coldK)[] VspParams; + internal readonly (string name, double vsp, double density)[] VspInfo; + internal readonly (double dewarArea, double coldK)[] DewarParams; + internal readonly (string name, double vsp, double density)[] DewarInfo; + + internal BgBoiloffCache(string dataVersion, int mliLayers, double tankAreaM2, + (double conductW, double coldK)[] vspParams, (string name, double vsp, double density)[] vspInfo, + (double dewarArea, double coldK)[] dewarParams, (string name, double vsp, double density)[] dewarInfo) + { + DataVersion = dataVersion; + MliLayers = mliLayers; + TankAreaM2 = tankAreaM2; + VspParams = vspParams; + VspInfo = vspInfo; + DewarParams = dewarParams; + DewarInfo = dewarInfo; + } + } +} diff --git a/Source/Tanks/ModuleFuelTanksRF.cs b/Source/Tanks/ModuleFuelTanksRF.cs index 0d867b38..128f5252 100644 --- a/Source/Tanks/ModuleFuelTanksRF.cs +++ b/Source/Tanks/ModuleFuelTanksRF.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Runtime.CompilerServices; using UnityEngine; using UnityEngine.Profiling; @@ -76,6 +77,15 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr private readonly List lossInfo = new List(); private readonly List fluxInfo = new List(); + // Cached solver params for ComputeMLIEquilibriumTemp (values are fixed for a given tank definition). + private (double conductW, double coldK)[] _mliTankParamsCache; + private (double dewarArea, double coldK)[] _mliDewarParamsCache; + + // Pre-parsed tank boiloff data for background processing. + // Keys naturally expire when the vessel loads and KSP releases the snapshot, so no manual cleanup is needed. + private static readonly ConditionalWeakTable _bgCache + = new ConditionalWeakTable(); + // for EngineIgnitor integration: store a public dictionary of all pressurized propellants [NonSerialized] public Dictionary pressurizedFuels = new Dictionary(); @@ -649,8 +659,18 @@ private double ComputeMLIEquilibriumTemp(double skinTemp) if (totalMLILayers <= 0 || totalTankArea <= 0) return skinTemp; + if (_mliTankParamsCache == null) + BuildMLISolverParams(); + + return _mliTankParamsCache.Length > 0 || _mliDewarParamsCache.Length > 0 + ? SolveMLIEquilibrium(skinTemp, totalTankArea, totalMLILayers, _mliTankParamsCache, _mliDewarParamsCache) + : skinTemp; + } + + private void BuildMLISolverParams() + { var tankParams = new List<(double conductW, double coldK)>(cryoTanks.Count); - var dewarParams = new List<(double area, double coldK)>(); + var dewarParams = new List<(double dewarArea, double coldK)>(); foreach (var tank in cryoTanks) { if (tank.vsp <= 0) continue; @@ -664,14 +684,11 @@ private double ComputeMLIEquilibriumTemp(double skinTemp) double wallF = tank.wallConduction > 0 ? tank.wallThickness / tank.wallConduction : 0; double insulF = tank.insulationConduction > 0 ? tank.insulationThickness / tank.insulationConduction : 0; double resF = tank.resourceConductivity > 0 ? 0.01 / tank.resourceConductivity : 0; - double conductW = tank.totalArea / Math.Max(double.Epsilon, wallF + insulF + resF); - tankParams.Add((conductW, tank.temperature)); + tankParams.Add((tank.totalArea / Math.Max(double.Epsilon, wallF + insulF + resF), tank.temperature)); } } - - return tankParams.Count > 0 || dewarParams.Count > 0 - ? SolveMLIEquilibrium(skinTemp, totalTankArea, totalMLILayers, tankParams, dewarParams) - : skinTemp; + _mliTankParamsCache = tankParams.ToArray(); + _mliDewarParamsCache = dewarParams.ToArray(); } /// @@ -681,8 +698,8 @@ private double ComputeMLIEquilibriumTemp(double skinTemp) /// private static double SolveMLIEquilibrium( double skinTemp, double tankAreaM2, int mliLayers, - List<(double conductW, double coldK)> tanks, - List<(double area, double coldK)> dewarTanks) + IReadOnlyList<(double conductW, double coldK)> tanks, + IReadOnlyList<(double area, double coldK)> dewarTanks) { double lo = double.MaxValue; foreach (var t in tanks) @@ -748,81 +765,48 @@ public static string BackgroundUpdate( bool hasGeometry = KerbalismInterface.TryGetThermalData(vessel, out double vesselTemp, out _); - // Read MLI geometry needed for the equilibrium solver. - int.TryParse(proto_module.moduleValues.GetValue(nameof(totalMLILayers)), out int mliLayers); - double.TryParse(proto_module.moduleValues.GetValue(nameof(totalTankArea)), - NumberStyles.Float, CultureInfo.InvariantCulture, out double tankAreaM2); - - // Parse all entries. Separate Dewar tanks (handled individually) from vsp tanks - // (handled via joint MLI equilibrium). - var vspTanks = new List<(string name, double coldK, double conductW, PartResourceDefinition resDef)>(); - var dewarTanks = new List<(string name, double coldK, double dewarArea, PartResourceDefinition resDef)>(); - - foreach (string entry in data.Split(';')) + // bgBoiloffData, totalMLILayers, and totalTankArea are all static while a vessel is unloaded, + // so parse them once and cache on the ProtoPartModuleSnapshot (automatically GC'd when the + // vessel loads and the snapshot is released). + if (!_bgCache.TryGetValue(proto_module, out BgBoiloffCache cache) || cache.DataVersion != data) { - if (string.IsNullOrEmpty(entry)) continue; - string[] f = entry.Split(','); - if (f.Length != 4) continue; - string resourceName = f[0]; - if (!double.TryParse(f[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double coldK)) continue; - if (!double.TryParse(f[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double conductW)) continue; - if (!double.TryParse(f[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double dewarArea)) continue; - - PartResourceDefinition resDef = PartResourceLibrary.Instance.GetDefinition(resourceName); - if (resDef == null || resDef.density <= 0d) continue; - - if (dewarArea >= 0) - dewarTanks.Add((resourceName, coldK, dewarArea, resDef)); - else if (conductW > 0 && MFSSettings.resourceVsps.ContainsKey(resourceName)) - vspTanks.Add((resourceName, coldK, conductW, resDef)); + int.TryParse(proto_module.moduleValues.GetValue(nameof(totalMLILayers)), out int mliLayers); + double.TryParse(proto_module.moduleValues.GetValue(nameof(totalTankArea)), + NumberStyles.Float, CultureInfo.InvariantCulture, out double tankAreaM2); + cache = BuildBgBoiloffCache(data, mliLayers, tankAreaM2); + _bgCache.Remove(proto_module); + _bgCache.Add(proto_module, cache); } bool anyRequest = false; - // Solve the shared part-interior temperature, accounting for MLI and all cryo heat sinks. - // Requires Kerbalism VesselTemperature; boiloff is skipped entirely if geometry data is unavailable. - if (hasGeometry) + if (hasGeometry && (cache.VspParams.Length > 0 || cache.DewarParams.Length > 0)) { - double interiorTemp; - if (mliLayers > 0 && tankAreaM2 > 0 && (vspTanks.Count > 0 || dewarTanks.Count > 0)) - { - var tankParams = new List<(double conductW, double coldK)>(vspTanks.Count); - foreach (var t in vspTanks) - { - tankParams.Add((t.conductW, t.coldK)); - } - - var dewarParams = new List<(double area, double coldK)>(dewarTanks.Count); - foreach (var d in dewarTanks) - { - dewarParams.Add((d.dewarArea, d.coldK)); - } + double interiorTemp = cache.MliLayers > 0 && cache.TankAreaM2 > 0 + ? SolveMLIEquilibrium(vesselTemp, cache.TankAreaM2, cache.MliLayers, cache.VspParams, cache.DewarParams) + : vesselTemp; - interiorTemp = SolveMLIEquilibrium(vesselTemp, tankAreaM2, mliLayers, tankParams, dewarParams); - } - else - { - interiorTemp = vesselTemp; // no MLI: interior equilibrates to skin temperature - } - - foreach (var (name, coldK, conductW, resDef) in vspTanks) + for (int i = 0; i < cache.VspParams.Length; i++) { - if (!MFSSettings.resourceVsps.TryGetValue(name, out double vsp) || vsp <= 0) continue; + var (conductW, coldK) = cache.VspParams[i]; + var (name, vsp, density) = cache.VspInfo[i]; double deltaTemp = interiorTemp - coldK; if (deltaTemp <= 0) continue; double rateKgS = conductW * deltaTemp * 0.001 / vsp; if (rateKgS <= 0) continue; - resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / resDef.density)); + resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / density)); anyRequest = true; } - foreach (var (name, coldK, dewarArea, resDef) in dewarTanks) + for (int i = 0; i < cache.DewarParams.Length; i++) { - if (interiorTemp <= coldK || !MFSSettings.resourceVsps.TryGetValue(name, out double vsp) || vsp <= 0) continue; + var (dewarArea, coldK) = cache.DewarParams[i]; + var (name, vsp, density) = cache.DewarInfo[i]; + if (interiorTemp <= coldK) continue; double Q_kW = GetDewarTransferRate(interiorTemp, coldK, dewarArea) * 0.001; double rateKgS = Math.Max(0, Q_kW / vsp); if (rateKgS <= 0) continue; - resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / resDef.density)); + resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / density)); anyRequest = true; } } @@ -833,6 +817,44 @@ public static string BackgroundUpdate( return anyRequest ? Localizer.GetStringByTag("#RF_FuelTankRF_kerbalismtips") : string.Empty; } + private static BgBoiloffCache BuildBgBoiloffCache(string data, int mliLayers, double tankAreaM2) + { + var vspParams = new List<(double conductW, double coldK)>(); + var vspInfo = new List<(string name, double vsp, double density)>(); + var dewarParams = new List<(double dewarArea, double coldK)>(); + var dewarInfo = new List<(string name, double vsp, double density)>(); + + foreach (string entry in data.Split(';')) + { + if (string.IsNullOrEmpty(entry)) continue; + string[] split = entry.Split(','); + if (split.Length != 4) continue; + string resourceName = split[0]; + if (!double.TryParse(split[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double coldK)) continue; + if (!double.TryParse(split[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double conductW)) continue; + if (!double.TryParse(split[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double dewarArea)) continue; + + PartResourceDefinition resDef = PartResourceLibrary.Instance.GetDefinition(resourceName); + if (resDef == null || resDef.density <= 0d) continue; + if (!MFSSettings.resourceVsps.TryGetValue(resourceName, out double vsp) || vsp <= 0) continue; + + if (dewarArea >= 0) + { + dewarParams.Add((dewarArea, coldK)); + dewarInfo.Add((resourceName, vsp, resDef.density)); + } + else if (conductW > 0) + { + vspParams.Add((conductW, coldK)); + vspInfo.Add((resourceName, vsp, resDef.density)); + } + } + + return new BgBoiloffCache(data, mliLayers, tankAreaM2, + vspParams.ToArray(), vspInfo.ToArray(), + dewarParams.ToArray(), dewarInfo.ToArray()); + } + /// /// Called by Kerbalism every frame for loaded vessels. Uses their resource system when Kerbalism is installed. /// From f729f92e188e0c3e531c2b6913b876bb04b1338a Mon Sep 17 00:00:00 2001 From: siimav <1120038+siimav@users.noreply.github.com> Date: Thu, 21 May 2026 01:27:39 +0300 Subject: [PATCH 3/4] Completely redo boiloff model --- RealFuels/Localization/en-us.cfg | 1 - RealFuels/Localization/pt-br.cfg | 1 - RealFuels/Localization/ru.cfg | 1 - RealFuels/Localization/zh-cn.cfg | 1 - Source/Tanks/BgBoiloffCache.cs | 39 +- Source/Tanks/FuelTank.cs | 15 +- Source/Tanks/MFSSettings.cs | 6 +- Source/Tanks/ModuleFuelTanksRF.cs | 626 +++++++++++++----------------- 8 files changed, 298 insertions(+), 392 deletions(-) diff --git a/RealFuels/Localization/en-us.cfg b/RealFuels/Localization/en-us.cfg index edc44872..7347a6ec 100644 --- a/RealFuels/Localization/en-us.cfg +++ b/RealFuels/Localization/en-us.cfg @@ -178,7 +178,6 @@ Localization #RF_FuelTankRF_WallTemp = Wall Temp #RF_FuelTankRF_HeatPenetration = Heat Penetration #RF_FuelTankRF_BoiloffLoss = Boil-off Loss - #RF_FuelTankRF_AnalyticCooling = Analytic Cooling #RF_FuelTankRF_NoMLI = No MLI #RF_FuelTankRF_Boiloffunit = kg/hr #RF_FuelTankRF_kerbalismtips = boiloff product diff --git a/RealFuels/Localization/pt-br.cfg b/RealFuels/Localization/pt-br.cfg index e8f1b1f6..a332fc23 100644 --- a/RealFuels/Localization/pt-br.cfg +++ b/RealFuels/Localization/pt-br.cfg @@ -174,7 +174,6 @@ Localization #RF_FuelTankRF_WallTemp = Temp. da Parede #RF_FuelTankRF_HeatPenetration = Penetração de Calor #RF_FuelTankRF_BoiloffLoss = Perda por Evaporação - #RF_FuelTankRF_AnalyticCooling = Resfriamento Analítico #RF_FuelTankRF_NoMLI = Sem MLI #RF_FuelTankRF_Boiloffunit = kg/h #RF_FuelTankRF_kerbalismtips = produto de evaporação diff --git a/RealFuels/Localization/ru.cfg b/RealFuels/Localization/ru.cfg index 0aaa0f07..2c145c85 100644 --- a/RealFuels/Localization/ru.cfg +++ b/RealFuels/Localization/ru.cfg @@ -174,7 +174,6 @@ Localization #RF_FuelTankRF_WallTemp = Температура стенок #RF_FuelTankRF_HeatPenetration = Проникащее тепло #RF_FuelTankRF_BoiloffLoss = Потери на испарение - #RF_FuelTankRF_AnalyticCooling = Аналитическое охлаждение #RF_FuelTankRF_NoMLI = Без ЭВТИ #RF_FuelTankRF_Boiloffunit = кг/ч #RF_FuelTankRF_kerbalismtips = резальтат испарения diff --git a/RealFuels/Localization/zh-cn.cfg b/RealFuels/Localization/zh-cn.cfg index c05e4a4c..6fc29ad2 100644 --- a/RealFuels/Localization/zh-cn.cfg +++ b/RealFuels/Localization/zh-cn.cfg @@ -174,7 +174,6 @@ Localization #RF_FuelTankRF_WallTemp = 壁面温度 #RF_FuelTankRF_HeatPenetration = 热渗透 #RF_FuelTankRF_BoiloffLoss = 蒸发损耗 - #RF_FuelTankRF_AnalyticCooling = 冷却分析 #RF_FuelTankRF_NoMLI = 无隔热层 #RF_FuelTankRF_Boiloffunit = 千克/时 #RF_FuelTankRF_kerbalismtips = 蒸发产出 diff --git a/Source/Tanks/BgBoiloffCache.cs b/Source/Tanks/BgBoiloffCache.cs index 3ecc8b64..741d4ea8 100644 --- a/Source/Tanks/BgBoiloffCache.cs +++ b/Source/Tanks/BgBoiloffCache.cs @@ -1,33 +1,32 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace RealFuels.Tanks { internal sealed class BgBoiloffCache { internal readonly string DataVersion; internal readonly int MliLayers; - internal readonly double TankAreaM2; - // Arrays are paired up: VspParams[i] and VspInfo[i]; DewarParams[i] and DewarInfo[i] - internal readonly (double conductW, double coldK)[] VspParams; - internal readonly (string name, double vsp, double density)[] VspInfo; - internal readonly (double dewarArea, double coldK)[] DewarParams; - internal readonly (string name, double vsp, double density)[] DewarInfo; + internal readonly BgTankEntry[] Tanks; + internal readonly double[] InternalTemps; // mutable, parallel to Tanks; -1 = uninitialized - internal BgBoiloffCache(string dataVersion, int mliLayers, double tankAreaM2, - (double conductW, double coldK)[] vspParams, (string name, double vsp, double density)[] vspInfo, - (double dewarArea, double coldK)[] dewarParams, (string name, double vsp, double density)[] dewarInfo) + internal BgBoiloffCache(string dataVersion, int mliLayers, BgTankEntry[] tanks) { DataVersion = dataVersion; MliLayers = mliLayers; - TankAreaM2 = tankAreaM2; - VspParams = vspParams; - VspInfo = vspInfo; - DewarParams = dewarParams; - DewarInfo = dewarInfo; + Tanks = tanks; + InternalTemps = new double[tanks.Length]; + for (int i = 0; i < tanks.Length; i++) InternalTemps[i] = -1d; } } + + internal struct BgTankEntry + { + internal string Name; + internal double Vsp; // kJ/t + internal double Density; // t/unit + internal double BoilingPointK; + internal double TankAreaM2; // per-tank surface area; used by MLI and Dewar formulas + internal double ConductWPerK; // wall conductance for non-MLI tanks; 0 for MLI/Dewar + internal bool IsDewar; + internal double Hsp; // specific heat capacity, kJ/(t·K) + internal double StructThermalMassKJ; // structural thermal mass contribution for this tank, kJ/K + } } diff --git a/Source/Tanks/FuelTank.cs b/Source/Tanks/FuelTank.cs index d6423741..ec16fe31 100644 --- a/Source/Tanks/FuelTank.cs +++ b/Source/Tanks/FuelTank.cs @@ -34,7 +34,8 @@ public class FuelTank : IConfigNode public float mass = 0.0f; [Persistent] public float cost = 0.0f; - public double vsp; + public double hsp = 1000d; // specific heat capacity, kJ/(t·K), loaded from RESOURCE_DEFINITION + public double vsp; // heat of vapourization, kJ/t, loaded from RESOURCE_DEFINITION public double resourceConductivity = 10; @@ -55,6 +56,9 @@ public class FuelTank : IConfigNode [Persistent] public float temperature = 300.0f; + [Persistent] + public double internalTemp = -1d; // -1 = uninitialized; set on first OnStart + [Persistent] public bool fillable = true; [Persistent] @@ -301,7 +305,7 @@ public void Load(ConfigNode node) if (node.TryGetValue("boiloffProduct", ref boiloffRes)) boiloffProductResource = PartResourceLibrary.Instance.GetDefinition(boiloffRes); - GetDensity(); + Init(); } public void Save(ConfigNode node) @@ -377,14 +381,17 @@ internal FuelTank CreateCopy(ModuleFuelTanks toModule, ConfigNode overNode, bool else clone.amountExpression = clone.maxAmountExpression = null; - clone.GetDensity(); + clone.Init(); return clone; } - internal void GetDensity() + internal void Init() { PartResourceDefinition d = PartResourceLibrary.Instance.GetDefinition(name); density = (d != null) ? d.density : 0; + + if (!MFSSettings.resourceHsps.TryGetValue(name, out hsp) || hsp <= 0) + hsp = 1000d; } } } diff --git a/Source/Tanks/MFSSettings.cs b/Source/Tanks/MFSSettings.cs index defc4f4c..e58786d5 100644 --- a/Source/Tanks/MFSSettings.cs +++ b/Source/Tanks/MFSSettings.cs @@ -25,6 +25,7 @@ public class MFSSettings public static readonly Dictionary resourceVsps = new Dictionary(); public static readonly Dictionary resourceConductivities = new Dictionary(); + public static readonly Dictionary resourceHsps = new Dictionary(); private static readonly Dictionary overrides = new Dictionary(); @@ -72,8 +73,9 @@ public static void ModuleManagerPostLoad() { resourceVsps.Clear(); resourceConductivities.Clear(); + resourceHsps.Clear(); - // fill vsps & conductivities + // fill vsps, conductivities & heat capacities foreach (ConfigNode n in GameDatabase.Instance.GetConfigNodes("RESOURCE_DEFINITION")) { string nm = n.GetValue("name"); @@ -82,6 +84,8 @@ public static void ModuleManagerPostLoad() resourceVsps[nm] = dtmp; if (n.TryGetValue("conductivity", ref dtmp)) resourceConductivities[nm] = dtmp; + if (n.TryGetValue("hsp", ref dtmp)) + resourceHsps[nm] = dtmp; } ConfigNode node = GameDatabase.Instance.GetConfigNodes("MFSSETTINGS").LastOrDefault(); diff --git a/Source/Tanks/ModuleFuelTanksRF.cs b/Source/Tanks/ModuleFuelTanksRF.cs index 128f5252..98258f3a 100644 --- a/Source/Tanks/ModuleFuelTanksRF.cs +++ b/Source/Tanks/ModuleFuelTanksRF.cs @@ -1,4 +1,4 @@ -using KSP.Localization; +using KSP.Localization; using System; using System.Collections.Generic; using System.Globalization; @@ -37,16 +37,12 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr [KSPField(guiName = "#RF_FuelTankRF_BoiloffLoss", groupName = CryogenicGroupName)] // Boil-off Loss public string sBoiloffLoss; - [KSPField(guiName = "#RF_FuelTankRF_AnalyticCooling", groupName = CryogenicGroupName)] // Analytic Cooling - public string sAnalyticCooling; - - private double cooling = 0; - // Thermal data captured while loaded, used for processing boiloff BackgroundUpdate on unloaded vessels. - // Format per entry: "resourceName,coldTempK,conductInternalWPerK,dewarAreaM2" - // coldTempK — boiling point of the propellant (K) - // conductInternalWPerK — part-interior→liquid thermal conductance in W/K (0 for Dewar tanks) - // dewarAreaM2 — tank area for Stefan-Boltzmann Dewar formula (−1 for non-Dewar tanks) + // Format per entry: "resourceName,boilingPointK,tankAreaM2,conductWPerK,isDewar" + // boilingPointK — boiling point of the propellant (K) + // tankAreaM2 — per-tank surface area for MLI/Dewar formulas + // conductWPerK — wall conductance W/K for non-MLI tanks (0 for MLI and Dewar) + // isDewar — 1 for Dewar tanks, 0 otherwise [KSPField(isPersistant = true)] public string bgBoiloffData = ""; @@ -65,7 +61,6 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr public float MLIArealDensity = 0.000015f; private double analyticSkinTemp; - private double analyticInternalTemp; private readonly Dictionary boiloffProducts = new Dictionary(); public int numberOfMLILayers = 0; // base number of layers taken from TANK_DEFINITION configs @@ -77,12 +72,7 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr private readonly List lossInfo = new List(); private readonly List fluxInfo = new List(); - // Cached solver params for ComputeMLIEquilibriumTemp (values are fixed for a given tank definition). - private (double conductW, double coldK)[] _mliTankParamsCache; - private (double dewarArea, double coldK)[] _mliDewarParamsCache; - // Pre-parsed tank boiloff data for background processing. - // Keys naturally expire when the vessel loads and KSP releases the snapshot, so no manual cleanup is needed. private static readonly ConditionalWeakTable _bgCache = new ConditionalWeakTable(); @@ -126,33 +116,30 @@ partial void OnSaveRF(ConfigNode _) if (!HighLogic.LoadedSceneIsFlight || cryoTanks.Count == 0) return; - var bgEntries = new Dictionary(); + double structuralThermalMass = ComputeStructuralThermalMass(); + var entries = new List(cryoTanks.Count); foreach (var tank in cryoTanks) { - if (tank.amount <= 0) continue; + if (tank.amount <= 0 || tank.vsp <= 0) continue; - if (tank.vsp > 0) + double tankAreaM2 = tank.totalArea; + double conductWPerK = 0; + int isDewar = tank.isDewar ? 1 : 0; + + if (!tank.isDewar && totalMLILayers == 0) { - double conductInternalWPerK, dewarAreaM2; - if (tank.isDewar) - { - conductInternalWPerK = 0; - dewarAreaM2 = tank.totalArea; - } - else - { - double wallF = tank.wallConduction > 0 ? tank.wallThickness / tank.wallConduction : 0; - double insulF = tank.insulationConduction > 0 ? tank.insulationThickness / tank.insulationConduction : 0; - double resF = tank.resourceConductivity > 0 ? 0.01 / tank.resourceConductivity : 0; - conductInternalWPerK = tank.totalArea / Math.Max(double.Epsilon, wallF + insulF + resF); - dewarAreaM2 = -1; - } - bgEntries[tank.name] = string.Format(CultureInfo.InvariantCulture, "{0},{1:R},{2:R},{3:R}", - tank.name, tank.temperature, conductInternalWPerK, dewarAreaM2); + double wallF = tank.wallConduction > 0 ? tank.wallThickness / tank.wallConduction : 0; + double insulF = tank.insulationConduction > 0 ? tank.insulationThickness / tank.insulationConduction : 0; + double resF = tank.resourceConductivity > 0 ? 0.01 / tank.resourceConductivity : 0; + conductWPerK = tank.totalArea / Math.Max(double.Epsilon, wallF + insulF + resF); } + + entries.Add(string.Format(CultureInfo.InvariantCulture, "{0},{1:R},{2:R},{3:R},{4},{5:R},{6:R}", + tank.name, tank.temperature, tankAreaM2, conductWPerK, isDewar, + tank.hsp, structuralThermalMass * tank.tankRatio)); } - bgBoiloffData = bgEntries.Count > 0 ? string.Join(";", bgEntries.Values) : ""; + bgBoiloffData = entries.Count > 0 ? string.Join(";", entries) : ""; } partial void OnStartRF(StartState _) @@ -162,6 +149,9 @@ partial void OnStartRF(StartState _) foreach (var tank in tanksDict.Values) { + if (tank.internalTemp < 0) + tank.internalTemp = tank.vsp > 0 ? tank.temperature : (double.IsNaN(part.temperature) ? 300d : part.temperature); + if (tank.maxAmount > 0 && tank.vsp > 0) cryoTanks.Add(tank); } @@ -185,40 +175,11 @@ partial void OnStartRF(StartState _) Fields[nameof(sWallTemp)].guiActive = debugBoilActive; Fields[nameof(sHeatPenetration)].guiActive = debugBoilActive; Fields[nameof(sBoiloffLoss)].guiActive = debugBoilActive; - Fields[nameof(sAnalyticCooling)].guiActive = debugBoilActive; GameEvents.onPartResourceListChange.Add(OnPartResourceListChange); GameEvents.onPartDestroyed.Add(OnPartDestroyed); } - private void CalculateInsulation() - { - Profiler.BeginSample("CalculateInsulation"); - // TODO tie this into insulation configuration GUI! Also, we should handle MLI separately and as part skin-internal conduction. (DONE) - // Dewars and SOFI should be handled separately as part of the boiloff code on a per-tank basis (DONE) - // Current SOFI configuration system should be left in place with players able to add to tanks that don't have it. - if (totalMLILayers > 0 && totalVolume > 0 && !(double.IsNaN(part.temperature) || double.IsNaN(part.skinTemperature))) - { - double normalizationFactor = 1 / (PhysicsGlobals.SkinInternalConductionFactor * PhysicsGlobals.ConductionFactor * PhysicsGlobals.ThermalConvergenceFactor * 10 * 0.5); - double tDelta = part.skinTemperature - part.temperature; - if (tDelta == 0d) - tDelta = 0.00000000001d; - double insulationFactor = Math.Abs(GetMLITransferRate(part.skinTemperature, part.temperature) / tDelta) * 0.001; - double condRecip = part.partInfo.partPrefab.skinInternalConductionMult == 0d ? double.MaxValue : (1d / part.partInfo.partPrefab.skinInternalConductionMult); - part.heatConductivity = normalizationFactor * 1 / ((1 / insulationFactor) + condRecip); - CalculateAnalyticInsulationFactor(insulationFactor); - } - Profiler.EndSample(); - } - - private void CalculateAnalyticInsulationFactor(double insulationFactor) - { - double tMassRecip = part.thermalMass == 0d ? 1d : 1d / part.thermalMass; - part.analyticInternalInsulationFactor = _flightIntegrator is FlightIntegrator - ? (1d / PhysicsGlobals.AnalyticLerpRateInternal) * (insulationFactor * totalTankArea * tMassRecip) * RFSettings.Instance.analyticInsulationMultiplier * part.partInfo.partPrefab.analyticInternalInsulationFactor - : 0; - } - partial void CalculateMassRF(ref double mass) { mass += MLIArealDensity * totalTankArea * totalMLILayers; @@ -236,9 +197,13 @@ partial void UpdateRF() if (HighLogic.LoadedSceneIsFlight && (RFSettings.Instance.debugBoilOff || RFSettings.Instance.debugBoilOffPAW) && SupportsBoiloff && UIPartActionController.Instance.GetItem(part) != null) { - string MLIText = totalMLILayers > 0 ? $"{GetMLITransferRate(part.skinTemperature, part.temperature):F2} W/m²" : Localizer.GetStringByTag("#RF_FuelTankRF_NoMLI"); // "No MLI" - sWallTemp = $"{part.temperature:F2} ({MLIText} * {part.radiativeArea:F2} m²)"; // - sAnalyticCooling = Utilities.FormatFlux(cooling); + sWallTemp = ""; + foreach (var tank in cryoTanks) + sWallTemp += $"{tank.internalTemp:F2} | "; + if (!string.IsNullOrEmpty(sWallTemp)) + sWallTemp = sWallTemp.Remove(sWallTemp.Length - 3); + string MLIText = totalMLILayers > 0 ? $"{GetMLITransferRate(part.skinTemperature, lowestTankTemperature):F2} W/m²" : Localizer.GetStringByTag("#RF_FuelTankRF_NoMLI"); // "No MLI" + sWallTemp += $" ({MLIText} * {part.radiativeArea:F2} m²)"; sHeatPenetration = ""; sBoiloffLoss = ""; @@ -256,39 +221,43 @@ partial void UpdateRF() public void FixedUpdate() { - //print ("[Real Fuels]" + Time.time.ToString ()); if (HighLogic.LoadedSceneIsFlight && FlightGlobals.ready) { - // MLI performance varies by temperature delta - CalculateInsulation(); - - if(!_flightIntegrator.isAnalytical && SupportsBoiloff) - CalculateTankBoiloff(_flightIntegrator.timeSinceLastUpdate, _flightIntegrator.isAnalytical); + if (!_flightIntegrator.isAnalytical && SupportsBoiloff) + ProcessBoiloff(_flightIntegrator.timeSinceLastUpdate); } } - private void HandleCooling(ref double cooling, double deltaTime, bool analyticalMode) + /// + /// Returns incoming heat flux in W for a single tank from the part skin, + /// based on tank type and whether the part has MLI. + /// + private double GetIncomingFlux(double skinTemp, FuelTank tank) { - cooling = 0; - if (analyticalMode) - { - if (part.thermalInternalFlux < 0) - cooling = part.thermalInternalFlux; - else if (part.thermalInternalFluxPrevious < 0) - cooling = part.thermalInternalFluxPrevious; + if (tank.isDewar) + return GetDewarTransferRate(skinTemp, tank.internalTemp, tank.totalArea); - if (cooling < 0) - { - // in analytic mode, MFTRF interprets this as an attempt to cool the tanks - // Questionable since the thermalInternalFlux is already tracking it?? - if (part.thermalMassReciprocal > 0d) - analyticInternalTemp += cooling * part.thermalMassReciprocal * deltaTime; - } - } + if (totalMLILayers > 0) + return GetMLITransferRate(skinTemp, tank.internalTemp) * tank.totalArea; + + return GetBoiloffTransferRate(skinTemp, tank.internalTemp, tank.totalArea, tank); } - private double GetBoiloffTransferRate(double deltaTemp, double wettedArea, in FuelTank tank) + /// + /// Returns the structural thermal mass of the part (kJ/K), i.e. part.thermalMass + /// minus the thermal contribution of all resources using KSP's standard specific heat. + /// + private double ComputeStructuralThermalMass() { + double resourceThermalMass = 0; + foreach (PartResource res in part.Resources) + resourceThermalMass += res.amount * res.info.density * PhysicsGlobals.StandardSpecificHeatCapacity; + return Math.Max(0, part.thermalMass - resourceThermalMass); + } + + private double GetBoiloffTransferRate(double outerTemperature, double innerTemperature, double wettedArea, in FuelTank tank) + { + double deltaTemp = outerTemperature - innerTemperature; double wallFactor = tank.wallConduction > 0 ? tank.wallThickness / tank.wallConduction : 0; double insulationFactor = tank.insulationConduction > 0 ? tank.insulationThickness / tank.insulationConduction : 0; double resourceFactor = tank.resourceConductivity > 0 ? 0.01 / tank.resourceConductivity : 0; @@ -297,7 +266,7 @@ private double GetBoiloffTransferRate(double deltaTemp, double wettedArea, in Fu return deltaTemp * wettedArea / divisor; } - private void CalculateTankBoiloff(double deltaTime, bool analyticalMode = false, double unclampedIntScalar = 0, double unclampedSkinScalar = 0) + private void ProcessBoiloff(double deltaTime, bool analyticalMode = false) { Profiler.BeginSample("CalculateTankBoiloff"); if (totalTankArea <= 0) @@ -306,9 +275,11 @@ private void CalculateTankBoiloff(double deltaTime, bool analyticalMode = false, CalculateTankArea(); } - if (double.IsNaN(part.temperature)) + double skinTemp = analyticalMode ? analyticSkinTemp : part.skinTemperature; + + if (double.IsNaN(skinTemp)) { - Debug.LogError($"RF: CalculateTankBoiloff found NaN part.temperature on {part}"); + Debug.LogError($"RF: CalculateTankBoiloff found NaN skinTemperature on {part}"); Profiler.EndSample(); return; } @@ -325,98 +296,111 @@ private void CalculateTankBoiloff(double deltaTime, bool analyticalMode = false, { if (hasCryoFuels) { - if (analyticalMode) - analyticInternalTemp = lowestTankTemperature; - else - part.temperature = lowestTankTemperature; - // part.skinTemperature or analyticSkinTemp ? Nah. + foreach (var tank in cryoTanks) + tank.internalTemp = tank.temperature; } fueledByLaunchClamp = false; Profiler.EndSample(); return; } - if (deltaTime > 0 && !CheatOptions.InfinitePropellant) + if (deltaTime <= 0 || CheatOptions.InfinitePropellant) { - //Debug.Log($"internalFlux = {part.thermalInternalFlux}, thermalInternalFluxPrevious = {part.thermalInternalFluxPrevious}, analytic internal flux = {previewInternalFluxAdjust}"); - HandleCooling(ref cooling, deltaTime, analyticalMode); + Profiler.EndSample(); + return; + } - foreach (var tank in cryoTanks) - { - double tankAmount = tank.amount; - if (tankAmount > 0 && tank.vsp > 0) - { - double massLost = 0; - double hotTemp = part.temperature; + // TODO: structuralThermalMass should be split up per-tank + // TODO2: KSP will internally still assign a part.thermalMass value that includes resources + double structuralThermalMass = ComputeStructuralThermalMass(); + double totalAbsorbedQ_kW = 0d; - // We might be in analytic mode, and have a target temperature = analyticInternalTemp/analyticSkinTemp, and "progress" towards it reprsented by the scalar params - if (analyticalMode) - { - hotTemp = UtilMath.Lerp(part.temperature, analyticInternalTemp, Math.Min(1, unclampedIntScalar / 2)); - DebugLog($"[MFTRF] CalculateBoiloff.Analytic using adjusted temp {hotTemp:F1} from {part.temperature:F1} towards {analyticInternalTemp:F1} based on scalar {unclampedIntScalar:F2}"); - } + foreach (FuelTank tank in tanksDict.Values) + { + totalAbsorbedQ_kW += CalculateBoiloffForTank(tank, deltaTime, skinTemp, structuralThermalMass); + } - double deltaTemp = hotTemp - tank.temperature; - double Q = 0; - if (deltaTemp > 0) - { - double wettedArea = tank.totalArea; // disabled until proper wetted vs ullage conduction can be done (tank.amount / tank.maxAmount); - Q = tank.isDewar ? GetDewarTransferRate(hotTemp, tank.temperature, tank.totalArea) - : GetBoiloffTransferRate(deltaTemp, wettedArea, tank); + // Feed the absorbed heat back as a skin heat sink. AddSkinThermalFlux takes kW and + // multiplies by TimeWarp.fixedDeltaTime internally, so passing the rate is correct here. + // Skipped in analytic mode: should only have a very small effect. + if (!analyticalMode && totalAbsorbedQ_kW != 0) + part.AddSkinThermalFlux(-totalAbsorbedQ_kW); - Q *= 0.001d; // convert to kilowatts - massLost = Q / tank.vsp; + Profiler.EndSample(); + } - lossInfo.Add(massLost * 1000 * 3600); - fluxInfo.Add(Q); - massLost *= deltaTime; // Frame scaling - } + private double CalculateBoiloffForTank(FuelTank tank, double deltaTime, double skinTemp, double structuralThermalMass) + { + if (tank.totalArea <= 0 || tank.tankRatio <= 0) + return 0d; - double d = tank.density > 0 ? tank.density : 1; - double lossAmount = massLost / d; - lossAmount = Math.Min(lossAmount, tankAmount); + double Q_kW = GetIncomingFlux(skinTemp, tank) * 0.001d; - if (lossAmount > 0) - { - // operate directly with the PartResource because FuelTank.amount isn't really meant for in-flight resource consumption - tank.resource.amount -= lossAmount; - - // See if there is boiloff byproduct and see if any other parts want to accept it. - if (tank.boiloffProductResource != null) - { - double boiloffProductAmount = -(massLost / tank.boiloffProductResource.density); - double retainedAmount = part.RequestResource(tank.boiloffProductResource.id, boiloffProductAmount, ResourceFlowMode.STAGE_PRIORITY_FLOW, Utilities.KerbalismFound); - massLost -= retainedAmount * tank.boiloffProductResource.density; - - if (Utilities.KerbalismFound) - { - string rName = tank.boiloffProductResource.name; - retainedAmount /= deltaTime; - boiloffProducts[rName] = boiloffProducts.TryGetValue(rName, out double v) ? v + retainedAmount : retainedAmount; - } - } - } + double thermalMass = structuralThermalMass * tank.tankRatio + + tank.amount * tank.density * tank.hsp; + thermalMass = Math.Max(thermalMass, 1.0); + + if (tank.vsp <= 0 || tank.internalTemp < tank.temperature) + { + if (tank.vsp <= 0) + { + // Non-cryo: clamp internalTemp at skinTemp to prevent overshoot. + // At high warp the large deltaTime can push internalTemp past skinTemp in a + // single step; the resulting oscillation with asymmetric flux handling bleeds + // energy from the skin each cycle, eventually driving it to 0 K (or infinity). + double prevTemp = tank.internalTemp; + double newTemp = prevTemp + Q_kW * deltaTime / thermalMass; + newTemp = Q_kW >= 0 ? Math.Min(newTemp, skinTemp) : Math.Max(newTemp, skinTemp); + tank.internalTemp = newTemp; + return (newTemp - prevTemp) * thermalMass / deltaTime; + } + else + { + // Sub-boiling cryo: heat toward boiling point + tank.internalTemp += Q_kW * deltaTime / thermalMass; + if (tank.internalTemp > tank.temperature) + tank.internalTemp = tank.temperature; + return Q_kW > 0 ? Q_kW : 0d; + } + } + else + { + // At or above boiling point: phase transition holds temperature, all flux → boiloff + tank.internalTemp = tank.temperature; + + double tankAmount = tank.amount; + if (tankAmount <= 0 || Q_kW <= 0) return 0d; + + double massLost = Q_kW / tank.vsp * deltaTime; - boiloffMassT += massLost; + lossInfo.Add(Q_kW / tank.vsp * 1000d * 3600d); // kg/hr for display + fluxInfo.Add(Q_kW); - // subtract heat from boiloff - // subtracting heat in analytic mode is tricky: Analytic flux handling is 'cheaty' and tricky to predict. + double d = tank.density > 0 ? tank.density : 1; + double lossAmount = Math.Min(massLost / d, tankAmount); - if (Q > 0) + if (lossAmount > 0) + { + tank.resource.amount -= lossAmount; + + if (tank.boiloffProductResource != null) + { + double boiloffProductAmount = -(massLost / tank.boiloffProductResource.density); + double retainedAmount = part.RequestResource(tank.boiloffProductResource.id, boiloffProductAmount, ResourceFlowMode.STAGE_PRIORITY_FLOW, Utilities.KerbalismFound); + massLost -= retainedAmount * tank.boiloffProductResource.density; + + if (Utilities.KerbalismFound) { - double heatLost = -Q; - if (!analyticalMode) - part.AddThermalFlux(heatLost); - else - { - analyticInternalTemp += heatLost * part.thermalMassReciprocal * deltaTime; - DebugLog($"{part.name} deltaTime = {deltaTime:F2}s, heat lost = {heatLost:F4}, thermalMassReciprocal = {part.thermalMassReciprocal:F6}"); - } + string rName = tank.boiloffProductResource.name; + retainedAmount /= deltaTime; + boiloffProducts[rName] = boiloffProducts.TryGetValue(rName, out double v) ? v + retainedAmount : retainedAmount; } } } + + boiloffMassT += massLost; + return Q_kW; } - Profiler.EndSample(); } partial void UpdateTankTypeRF(TankDefinition def) @@ -458,11 +442,10 @@ private void UpdateEngineIgnitor(TankDefinition def) public void OnResourceMaxChanged(BaseEventDetails _) => CalculateTankArea(); private void OnPartResourceListChange(Part p) { - if (p == part) + if (p == part) CalculateTankArea(); } - // This is how you update drag cubes, we shouldn't be the service for this, but left-over code. private void UpdateDragCubes() { DragCube dragCube = DragCubeSystem.Instance.RenderProceduralDragCube(part); @@ -474,15 +457,8 @@ private void UpdateDragCubes() public void CalculateTankArea() { - // TODO: Codify a more accurate tank area calculator. - // Thought: cube YN/YP can be used to find the part diameter / circumference... X or Z finds the length - // Also should try to determine if tank has a common bulkhead - and adjust heat flux into individual tanks accordingly SetTankAreaInfo(volume); - // This allows a rough guess as to individual tank surface area based on ratio of tank volume to total volume but it breaks down at very small fractions - // So use greater of spherical calculation and tank ratio of total area. - // if for any reason our totalTankArea is still 0 (no drag cubes available yet or analytic temp routines executed first) - // then we're going to be defaulting to spherical calculation double areaSpherical = SphericalAreaFromVolume(totalVolume); double areaPartsSpherical = CalculateTankAreaFromSphericalSubTanks(); @@ -515,22 +491,13 @@ private bool CalculateLowestTankTemperature() } #region IAnalyticTemperatureModifier - // Analytic Interface public void SetAnalyticTemperature(FlightIntegrator fi, double analyticTemp, double predictedInternalTemp, double predictedSkinTemp) { analyticSkinTemp = predictedSkinTemp; - analyticInternalTemp = predictedInternalTemp; if (SupportsBoiloff) { - DebugLog($"{part.name} Analytic Temp = {analyticTemp:F2}, Analytic Internal = {predictedInternalTemp:F2}, Analytic Skin = {predictedSkinTemp:F2}"); - double lerpScalarInt = PhysicsGlobals.AnalyticLerpRateInternal * fi.timeSinceLastUpdate; - double lerpScalarSkin = PhysicsGlobals.AnalyticLerpRateSkin * fi.timeSinceLastUpdate; - double skinScalar = lerpScalarSkin * part.analyticSkinInsulationFactor; - double intScalar = lerpScalarInt * part.analyticInternalInsulationFactor; - // A value of 1.0 (unclamped) indicates the time that has passed == the expected time to equalize temperatures - // For values <= 1-ish, we may consider trying to scale the temp progress down by accounting for boiloff. - // Alternatively, just adjust the analytic output using the boiloff calculation anyway. + DebugLog($"{part.name} Analytic Temp = {analyticTemp:F2}, Analytic Skin = {predictedSkinTemp:F2}"); if (fi.timeSinceLastUpdate < double.MaxValue) { @@ -538,7 +505,6 @@ public void SetAnalyticTemperature(FlightIntegrator fi, double analyticTemp, dou if (bgBoiloffLastUpdate > 0d) { remainingTime = Math.Max(0d, Planetarium.GetUniversalTime() - bgBoiloffLastUpdate); - analyticInternalTemp = ComputeMLIEquilibriumTemp(analyticSkinTemp); bgBoiloffLastUpdate = 0d; } else @@ -547,13 +513,13 @@ public void SetAnalyticTemperature(FlightIntegrator fi, double analyticTemp, dou } if (remainingTime > 0d) - CalculateTankBoiloff(remainingTime, fi.isAnalytical, intScalar, skinScalar); + ProcessBoiloff(remainingTime, fi.isAnalytical); } else if (CalculateLowestTankTemperature()) { - // Vessel is freshly spawned and has cryogenic tanks, set temperatures appropriately - analyticSkinTemp = lowestTankTemperature; - analyticInternalTemp = lowestTankTemperature; + // Vessel is freshly spawned with cryogenic tanks — initialise per-tank state only + foreach (var tank in cryoTanks) + tank.internalTemp = tank.temperature; } } } @@ -564,42 +530,21 @@ public double GetSkinTemperature(out bool lerp) return Math.Max(analyticSkinTemp, PhysicsGlobals.SpaceTemperature); } + // Return skin temp as the internal temp: the part structure has no meaningful insulation + // from the skin surface, so it equilibrates with it. Propellant thermal state is managed + // independently via per-tank internalTemp and must not inflate part.thermalMass here. public double GetInternalTemperature(out bool lerp) { lerp = false; - return Math.Max(analyticInternalTemp, PhysicsGlobals.SpaceTemperature); + return Math.Max(analyticSkinTemp, PhysicsGlobals.SpaceTemperature); } #endregion #region Analytic Preview Interface - - // We don't really implement this interface anymore. - // Boiloff should not be a significant portion of the flux generation that it will actively keep - // the vessel cool and will change the steady-state temperature that is being calculated/previewed here. - // Diff-Eq problem: this calculates the steady-state temp, of which boiloff result is an input. - // However, boiloff as a resource can be consumed, so the amount of time to target this steady state changes. - // - // Normally called every FixedUpdate by FlightIntegrator in Analytic Mode - // May be called outside of Analytic Mode if part.temp/part.skinTemp were out of bounds public void AnalyticInfo(FlightIntegrator fi, double sunAndBodyIn, double backgroundRadiation, double radArea, double absEmissRatio, double internalFlux, double convCoeff, double ambientTemp, double maxPartTemp) { if (!fi.isAnalytical) Debug.Log($"[MFTRF] AnalyticInfo called in non-analytic mode for {vessel}. dT: {fi.timeSinceLastUpdate:F2}s"); - /* - if (TimeWarp.CurrentRate == 1) - DebugLog("AnalyticInfo being called with: sunAndBodyIn = " + sunAndBodyIn.ToString() - + ", backgroundRadiation = " + backgroundRadiation.ToString() - + ", radArea = "+ radArea.ToString() - + ", absEmissRatio = " + absEmissRatio.ToString() - + ", internalFlux = " + internalFlux.ToString() - + ", convCoeff = " + convCoeff.ToString() - + ", ambientTemp = " + ambientTemp.ToString() - + ", maxPartTemp = " + maxPartTemp.ToString() - ); - //float deltaTime = (float)(Planetarium.GetUniversalTime() - vessel.lastUT); - //if (this.supportsBoiloff) - // CalculateTankBoiloff(TimeWarp.fixedDeltaTime, true); - */ } public double InternalFluxAdjust() => 0; @@ -617,9 +562,9 @@ void DebugLog(string msg) /// /// Transfer rate through multilayer insulation in watts/m2 via radiation, conduction and convection (conduction through gas in the layers). - /// Default hot and cold values of 300 / 70. Can be called in real time substituting skin temp and internal temp for hot and cold. + /// Can be called in real time substituting skin temp and internal temp for hot and cold. /// - private double GetMLITransferRate(double outerTemperature = 300, double innerTemperature = 70) + private double GetMLITransferRate(double outerTemperature, double innerTemperature) => GetMLITransferRate(outerTemperature, innerTemperature, totalMLILayers, vessel.staticPressurekPa); private static double GetMLITransferRate(double outerTemp, double innerTemp, int mliLayers, double pressureKPa = 0) @@ -638,8 +583,7 @@ private static double GetMLITransferRate(double outerTemp, double innerTemp, int } /// - /// Transfer rate through Dewar walls - /// This is simplified down to basic radiation formula using corrected emissivity values for concentric walls for sake of performance + /// Transfer rate through Dewar walls via radiation across the vacuum gap. /// private static double GetDewarTransferRate(double hot, double cold, double area) { @@ -648,107 +592,14 @@ private static double GetDewarTransferRate(double hot, double cold, double area) return PhysicsGlobals.StefanBoltzmanConstant * emissivity * area * (Math.Pow(hot,4) - Math.Pow(cold,4)); } - /// - /// Returns the steady-state interior temperature for MLI-insulated cryo tanks at the given skin temperature, - /// i.e. the point where heat conducted inward through the MLI blanket equals heat absorbed by boiloff. - /// - /// - /// - private double ComputeMLIEquilibriumTemp(double skinTemp) - { - if (totalMLILayers <= 0 || totalTankArea <= 0) - return skinTemp; - - if (_mliTankParamsCache == null) - BuildMLISolverParams(); - - return _mliTankParamsCache.Length > 0 || _mliDewarParamsCache.Length > 0 - ? SolveMLIEquilibrium(skinTemp, totalTankArea, totalMLILayers, _mliTankParamsCache, _mliDewarParamsCache) - : skinTemp; - } - - private void BuildMLISolverParams() - { - var tankParams = new List<(double conductW, double coldK)>(cryoTanks.Count); - var dewarParams = new List<(double dewarArea, double coldK)>(); - foreach (var tank in cryoTanks) - { - if (tank.vsp <= 0) continue; - - if (tank.isDewar) - { - dewarParams.Add((tank.totalArea, tank.temperature)); - } - else - { - double wallF = tank.wallConduction > 0 ? tank.wallThickness / tank.wallConduction : 0; - double insulF = tank.insulationConduction > 0 ? tank.insulationThickness / tank.insulationConduction : 0; - double resF = tank.resourceConductivity > 0 ? 0.01 / tank.resourceConductivity : 0; - tankParams.Add((tank.totalArea / Math.Max(double.Epsilon, wallF + insulF + resF), tank.temperature)); - } - } - _mliTankParamsCache = tankParams.ToArray(); - _mliDewarParamsCache = dewarParams.ToArray(); - } - - /// - /// Finds the shared part-interior equilibrium temperature given skin temperature and all cryo heat sinks. - /// Solves: GetMLITransferRate(skinTemp, T) × tankAreaM2 = Σ conductW_i × max(0, T − coldK_i) via bisection. - /// Left side is monotonically decreasing in T; right side increasing → unique root. - /// - private static double SolveMLIEquilibrium( - double skinTemp, double tankAreaM2, int mliLayers, - IReadOnlyList<(double conductW, double coldK)> tanks, - IReadOnlyList<(double area, double coldK)> dewarTanks) - { - double lo = double.MaxValue; - foreach (var t in tanks) - { - if (t.coldK < lo) lo = t.coldK; - } - - foreach (var d in dewarTanks) - { - if (d.coldK < lo) lo = d.coldK; - } - - double hi = skinTemp; - if (lo >= hi) return hi; - - const double tolerance = 1e-3; // 1 mK — well below any physical significance - while (hi - lo > tolerance) - { - double mid = (lo + hi) * 0.5; - double mliFlux = GetMLITransferRate(skinTemp, mid, mliLayers) * tankAreaM2; // TODO: currently assumes pressureKPa is always 0 for unloaded vessels (space vacuum, no convective contribution). - double internalFlux = 0; - foreach (var t in tanks) - { - internalFlux += t.conductW * Math.Max(0, mid - t.coldK); - } - - foreach (var d in dewarTanks) - { - if (mid > d.coldK) - internalFlux += GetDewarTransferRate(mid, d.coldK, d.area); - } - - if (mliFlux > internalFlux) lo = mid; - else hi = mid; - } - return (lo + hi) * 0.5; - } - #endregion #region Kerbalism /// /// Called by Kerbalism for unloaded (background) vessels via reflection. - /// Solves the MLI thermal equilibrium jointly for all non-Dewar cryo propellants in the part, - /// using Kerbalism's geometry+orientation-corrected VesselTemperature as the skin hot-side. - /// Solving jointly is essential: in a LH2+LOX tank, LH2's heat sink keeps T_interior below - /// LOX's boiling point, correctly suppressing LOX boiloff without any special-casing. - /// Falls back to the stored rate per-tank when geometry data is unavailable. + /// For each cryo propellant, computes boiloff directly from the Kerbalism vessel temperature + /// using the appropriate heat-transfer formula (MLI, conduction, or Dewar radiation). /// public static string BackgroundUpdate( Vessel vessel, @@ -764,95 +615,146 @@ public static string BackgroundUpdate( if (string.IsNullOrEmpty(data)) return string.Empty; bool hasGeometry = KerbalismInterface.TryGetThermalData(vessel, out double vesselTemp, out _); + if (!hasGeometry) return string.Empty; - // bgBoiloffData, totalMLILayers, and totalTankArea are all static while a vessel is unloaded, - // so parse them once and cache on the ProtoPartModuleSnapshot (automatically GC'd when the - // vessel loads and the snapshot is released). if (!_bgCache.TryGetValue(proto_module, out BgBoiloffCache cache) || cache.DataVersion != data) { int.TryParse(proto_module.moduleValues.GetValue(nameof(totalMLILayers)), out int mliLayers); - double.TryParse(proto_module.moduleValues.GetValue(nameof(totalTankArea)), - NumberStyles.Float, CultureInfo.InvariantCulture, out double tankAreaM2); - cache = BuildBgBoiloffCache(data, mliLayers, tankAreaM2); + cache = BuildBgBoiloffCache(data, mliLayers); _bgCache.Remove(proto_module); _bgCache.Add(proto_module, cache); } - bool anyRequest = false; + // On first use of a cache instance, seed InternalTemps from the persisted TANK nodes + bool needsInit = false; + for (int i = 0; i < cache.Tanks.Length; i++) + { + if (cache.InternalTemps[i] < 0) + { + needsInit = true; + break; + } + } - if (hasGeometry && (cache.VspParams.Length > 0 || cache.DewarParams.Length > 0)) + if (needsInit) + { + var tempLookup = new Dictionary(StringComparer.Ordinal); + foreach (ConfigNode tankNode in proto_module.moduleValues.GetNodes("TANK")) + { + string tName = tankNode.GetValue("name"); + string sVal = tankNode.GetValue("internalTemp"); + if (tName != null && double.TryParse(sVal, NumberStyles.Float, CultureInfo.InvariantCulture, out double t)) + tempLookup[tName] = t; + } + for (int i = 0; i < cache.Tanks.Length; i++) + { + if (cache.InternalTemps[i] < 0) + cache.InternalTemps[i] = tempLookup.TryGetValue(cache.Tanks[i].Name, out double v) && v > 0 + ? v : cache.Tanks[i].BoilingPointK; + } + } + + bool anyRequest = false; + for (int i = 0; i < cache.Tanks.Length; i++) { - double interiorTemp = cache.MliLayers > 0 && cache.TankAreaM2 > 0 - ? SolveMLIEquilibrium(vesselTemp, cache.TankAreaM2, cache.MliLayers, cache.VspParams, cache.DewarParams) - : vesselTemp; + BgTankEntry entry = cache.Tanks[i]; + double internalTemp = cache.InternalTemps[i]; - for (int i = 0; i < cache.VspParams.Length; i++) + double Q_kW; + if (entry.IsDewar) { - var (conductW, coldK) = cache.VspParams[i]; - var (name, vsp, density) = cache.VspInfo[i]; - double deltaTemp = interiorTemp - coldK; - if (deltaTemp <= 0) continue; - double rateKgS = conductW * deltaTemp * 0.001 / vsp; - if (rateKgS <= 0) continue; - resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / density)); - anyRequest = true; + Q_kW = GetDewarTransferRate(vesselTemp, internalTemp, entry.TankAreaM2) * 0.001; + if (Q_kW == 0) continue; + } + else if (cache.MliLayers > 0 && entry.TankAreaM2 > 0) + { + Q_kW = GetMLITransferRate(vesselTemp, internalTemp, cache.MliLayers) * entry.TankAreaM2 * 0.001; + if (Q_kW == 0) continue; + } + else if (entry.ConductWPerK > 0) + { + double deltaTemp = vesselTemp - internalTemp; + if (deltaTemp == 0) continue; + Q_kW = entry.ConductWPerK * deltaTemp * 0.001; } + else continue; - for (int i = 0; i < cache.DewarParams.Length; i++) + if (internalTemp < entry.BoilingPointK) { - var (dewarArea, coldK) = cache.DewarParams[i]; - var (name, vsp, density) = cache.DewarInfo[i]; - if (interiorTemp <= coldK) continue; - double Q_kW = GetDewarTransferRate(interiorTemp, coldK, dewarArea) * 0.001; - double rateKgS = Math.Max(0, Q_kW / vsp); - if (rateKgS <= 0) continue; - resourceChangeRequest.Add(new KeyValuePair(name, -rateKgS / density)); + // Sub-boiling: advance internalTemp through thermal mass + double amount = availableResources.TryGetValue(entry.Name, out double a) ? a : 0d; + double thermalMass = entry.StructThermalMassKJ + amount * entry.Density * entry.Hsp; + thermalMass = Math.Max(thermalMass, 1.0); + cache.InternalTemps[i] = Math.Min(internalTemp + Q_kW * elapsed_s / thermalMass, entry.BoilingPointK); + } + else if (Q_kW > 0) + { + // At boiling point: all flux drives boiloff + double rateKgS = Q_kW / entry.Vsp; + resourceChangeRequest.Add(new KeyValuePair(entry.Name, -rateKgS / entry.Density)); anyRequest = true; } } + // Persist updated internalTemps so they survive cache invalidation and vessel reload + foreach (ConfigNode tankNode in proto_module.moduleValues.GetNodes("TANK")) + { + string tName = tankNode.GetValue("name"); + if (tName == null) continue; + for (int i = 0; i < cache.Tanks.Length; i++) + { + if (cache.Tanks[i].Name == tName) + { + var sTemp = cache.InternalTemps[i].ToString("R", CultureInfo.InvariantCulture); + tankNode.SetValue("internalTemp", sTemp, true); + break; + } + } + } + proto_module.moduleValues.SetValue("bgBoiloffLastUpdate", Planetarium.GetUniversalTime().ToString(CultureInfo.InvariantCulture)); return anyRequest ? Localizer.GetStringByTag("#RF_FuelTankRF_kerbalismtips") : string.Empty; } - private static BgBoiloffCache BuildBgBoiloffCache(string data, int mliLayers, double tankAreaM2) + private static BgBoiloffCache BuildBgBoiloffCache(string data, int mliLayers) { - var vspParams = new List<(double conductW, double coldK)>(); - var vspInfo = new List<(string name, double vsp, double density)>(); - var dewarParams = new List<(double dewarArea, double coldK)>(); - var dewarInfo = new List<(string name, double vsp, double density)>(); + var tanks = new List(); foreach (string entry in data.Split(';')) { if (string.IsNullOrEmpty(entry)) continue; string[] split = entry.Split(','); - if (split.Length != 4) continue; + if (split.Length != 7) continue; + string resourceName = split[0]; - if (!double.TryParse(split[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double coldK)) continue; - if (!double.TryParse(split[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double conductW)) continue; - if (!double.TryParse(split[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double dewarArea)) continue; + if (!double.TryParse(split[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double boilingPointK)) continue; + if (!double.TryParse(split[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double tankAreaM2)) continue; + if (!double.TryParse(split[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double conductWPerK)) continue; + if (!int.TryParse(split[4], out int isDewarInt)) continue; + if (!double.TryParse(split[5], NumberStyles.Float, CultureInfo.InvariantCulture, out double hsp)) continue; + if (!double.TryParse(split[6], NumberStyles.Float, CultureInfo.InvariantCulture, out double structThermalMassKJ)) continue; PartResourceDefinition resDef = PartResourceLibrary.Instance.GetDefinition(resourceName); if (resDef == null || resDef.density <= 0d) continue; if (!MFSSettings.resourceVsps.TryGetValue(resourceName, out double vsp) || vsp <= 0) continue; - if (dewarArea >= 0) + tanks.Add(new BgTankEntry { - dewarParams.Add((dewarArea, coldK)); - dewarInfo.Add((resourceName, vsp, resDef.density)); - } - else if (conductW > 0) - { - vspParams.Add((conductW, coldK)); - vspInfo.Add((resourceName, vsp, resDef.density)); - } + Name = resourceName, + Vsp = vsp, + Density = resDef.density, + BoilingPointK = boilingPointK, + TankAreaM2 = tankAreaM2, + ConductWPerK = conductWPerK, + IsDewar = isDewarInt != 0, + Hsp = hsp, + StructThermalMassKJ = structThermalMassKJ, + }); } - return new BgBoiloffCache(data, mliLayers, tankAreaM2, - vspParams.ToArray(), vspInfo.ToArray(), - dewarParams.ToArray(), dewarInfo.ToArray()); + return new BgBoiloffCache(data, mliLayers, tanks.ToArray()); } /// @@ -860,8 +762,6 @@ private static BgBoiloffCache BuildBgBoiloffCache(string data, int mliLayers, do /// public virtual string ResourceUpdate(Dictionary availableResources, List> resourceChangeRequest) { - //resourceChangeRequest.Clear(); - foreach (var resourceRequest in boiloffProducts) { var definition = PartResourceLibrary.Instance.GetDefinition(resourceRequest.Key); From 17c42faa25c998ac41a11f9ee3ac8b3e67319a51 Mon Sep 17 00:00:00 2001 From: siimav <1120038+siimav@users.noreply.github.com> Date: Sat, 23 May 2026 03:31:08 +0300 Subject: [PATCH 4/4] Add cryocooler support --- RealFuels/CryoCooler.cfg | 63 +++++ RealFuels/Localization/en-us.cfg | 4 + RealFuels/Localization/pt-br.cfg | 4 + RealFuels/Localization/ru.cfg | 4 + RealFuels/Localization/zh-cn.cfg | 4 + RealFuels/MFSSettings.cfg | 4 +- Source/Tanks/BgBoiloffCache.cs | 96 +++++++- Source/Tanks/MFSSettings.cs | 2 - Source/Tanks/ModuleFuelTanksRF.cs | 383 ++++++++++++++++++++++-------- 9 files changed, 450 insertions(+), 114 deletions(-) create mode 100644 RealFuels/CryoCooler.cfg diff --git a/RealFuels/CryoCooler.cfg b/RealFuels/CryoCooler.cfg new file mode 100644 index 00000000..e73e48e3 --- /dev/null +++ b/RealFuels/CryoCooler.cfg @@ -0,0 +1,63 @@ +// Cryocooler defaults for RealFuels tanks. +// Refer to \RealismOverhaul\RO_RealFuels_Crycoolers.cfg for references and formulas. + +@PART:HAS[@MODULE[ModuleFuelTanks]:HAS[#type[Cryogenic]]]:AFTER[RealFuels] +{ + &rfAddCryocooler = true +} + +@PART:HAS[@MODULE[ModuleFuelTanks]:HAS[#type[BalloonCryo]]]:AFTER[RealFuels] +{ + &rfAddCryocooler = true +} + +@PART:HAS[@MODULE[ModuleFuelTanks]:HAS[#type[ServiceModule]]]:AFTER[RealFuels] +{ + &rfAddCryocooler = true +} + +@PART:HAS[#rfAddCryocooler[?rue]]:NEEDS[!RealismOverhaul]:AFTER[RealFuels] +{ + @MODULE[ModuleFuelTanks] + { + maxCoolerInputKW = 10 + coolerBaseMass = 0.01 + coolerMassPerKWInput + { + key = 4 0.120 + key = 20 0.060 + key = 40 0.030 + key = 65 0.025 + key = 90 0.020 + key = 150 0.015 + key = 250 0.010 + } + coolerBaseCost = 150 + coolerCostPerKWInput + { + key = 4 9000 + key = 20 4500 + key = 40 2250 + key = 65 1875 + key = 90 1500 + key = 150 1125 + key = 250 750 + } + cryoCoolerEfficiency + { + key = 0 0 + key = 4 0.01 + key = 20 0.08 + key = 65 0.20 + key = 90 0.25 + key = 150 0.40 + key = 250 0.50 + } + } +} + +// cleanup +@PART:HAS[#rfAddCryocooler]:AFTER[RealFuels] +{ + !rfAddCryocooler = delete +} diff --git a/RealFuels/Localization/en-us.cfg b/RealFuels/Localization/en-us.cfg index 7347a6ec..31fb8a73 100644 --- a/RealFuels/Localization/en-us.cfg +++ b/RealFuels/Localization/en-us.cfg @@ -181,6 +181,10 @@ Localization #RF_FuelTankRF_NoMLI = No MLI #RF_FuelTankRF_Boiloffunit = kg/hr #RF_FuelTankRF_kerbalismtips = boiloff product + #RF_FuelTankRF_CryoCoolerInputPower = Cooler Input + #RF_FuelTankRF_CryoCoolerLift = Cooling Lift + #RF_FuelTankRF_CryoCoolerDraw = Power Draw + #RF_FuelTankRF_CryoCoolerCOP = Avg COP #RF_TankDefineSelection_HighlyPressurized = Highly Pressurized #RF_TankDefineSelection_NotHighlyPressurized = Not Highly Pressurized diff --git a/RealFuels/Localization/pt-br.cfg b/RealFuels/Localization/pt-br.cfg index a332fc23..ef34580a 100644 --- a/RealFuels/Localization/pt-br.cfg +++ b/RealFuels/Localization/pt-br.cfg @@ -177,6 +177,10 @@ Localization #RF_FuelTankRF_NoMLI = Sem MLI #RF_FuelTankRF_Boiloffunit = kg/h #RF_FuelTankRF_kerbalismtips = produto de evaporação + #RF_FuelTankRF_CryoCoolerInputPower = Cooler Input + #RF_FuelTankRF_CryoCoolerLift = Cooling Lift + #RF_FuelTankRF_CryoCoolerDraw = Power Draw + #RF_FuelTankRF_CryoCoolerCOP = Avg COP #RF_TankDefineSelection_HighlyPressurized = Altamente Pressurizado #RF_TankDefineSelection_NotHighlyPressurized = Não Altamente Pressurizado diff --git a/RealFuels/Localization/ru.cfg b/RealFuels/Localization/ru.cfg index 2c145c85..4ab2f291 100644 --- a/RealFuels/Localization/ru.cfg +++ b/RealFuels/Localization/ru.cfg @@ -177,6 +177,10 @@ Localization #RF_FuelTankRF_NoMLI = Без ЭВТИ #RF_FuelTankRF_Boiloffunit = кг/ч #RF_FuelTankRF_kerbalismtips = резальтат испарения + #RF_FuelTankRF_CryoCoolerInputPower = Cooler Input + #RF_FuelTankRF_CryoCoolerLift = Cooling Lift + #RF_FuelTankRF_CryoCoolerDraw = Power Draw + #RF_FuelTankRF_CryoCoolerCOP = Avg COP #RF_TankDefineSelection_HighlyPressurized = Бак высокого давления #RF_TankDefineSelection_NotHighlyPressurized = Бак нормальн. давления diff --git a/RealFuels/Localization/zh-cn.cfg b/RealFuels/Localization/zh-cn.cfg index 6fc29ad2..13e32b3e 100644 --- a/RealFuels/Localization/zh-cn.cfg +++ b/RealFuels/Localization/zh-cn.cfg @@ -177,6 +177,10 @@ Localization #RF_FuelTankRF_NoMLI = 无隔热层 #RF_FuelTankRF_Boiloffunit = 千克/时 #RF_FuelTankRF_kerbalismtips = 蒸发产出 + #RF_FuelTankRF_CryoCoolerInputPower = Cooler Input + #RF_FuelTankRF_CryoCoolerLift = Cooling Lift + #RF_FuelTankRF_CryoCoolerDraw = Power Draw + #RF_FuelTankRF_CryoCoolerCOP = Avg COP #RF_TankDefineSelection_HighlyPressurized = 高压 #RF_TankDefineSelection_NotHighlyPressurized = 非高压 diff --git a/RealFuels/MFSSettings.cfg b/RealFuels/MFSSettings.cfg index 74b112d0..ac865d4f 100644 --- a/RealFuels/MFSSettings.cfg +++ b/RealFuels/MFSSettings.cfg @@ -5,9 +5,7 @@ MFSSETTINGS BatteryMultiplier = 1 basemassUseTotalVolume = True - - radiatorMinTempMult = 0.99 - + IgnoreFuelsForFill { IntakeAir = True diff --git a/Source/Tanks/BgBoiloffCache.cs b/Source/Tanks/BgBoiloffCache.cs index 741d4ea8..5c2f20b5 100644 --- a/Source/Tanks/BgBoiloffCache.cs +++ b/Source/Tanks/BgBoiloffCache.cs @@ -1,19 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + namespace RealFuels.Tanks { internal sealed class BgBoiloffCache { - internal readonly string DataVersion; internal readonly int MliLayers; internal readonly BgTankEntry[] Tanks; - internal readonly double[] InternalTemps; // mutable, parallel to Tanks; -1 = uninitialized + internal readonly double[] InternalTemps; // mutable, parallel to Tanks + internal readonly double[] FluxScratch; // mutable per-tick scratch for Q_kW pre-pass + + internal readonly double CoolerInputKW; // (0 if no cooler installed) + internal readonly double CoolerFrac; // fraction-of-Carnot at CoolerLowestTempK + internal readonly double CoolerLowestTempK; // cold-side T of the cooler - internal BgBoiloffCache(string dataVersion, int mliLayers, BgTankEntry[] tanks) + internal BgBoiloffCache(int mliLayers, BgTankEntry[] tanks, + double coolerInputKW, double coolerFrac, double coolerLowestTempK) { - DataVersion = dataVersion; MliLayers = mliLayers; Tanks = tanks; InternalTemps = new double[tanks.Length]; - for (int i = 0; i < tanks.Length; i++) InternalTemps[i] = -1d; + FluxScratch = new double[tanks.Length]; + CoolerInputKW = coolerInputKW; + CoolerFrac = coolerFrac; + CoolerLowestTempK = coolerLowestTempK; + } + + internal static BgBoiloffCache Build(string data, string coolerData, int mliLayers) + { + var tanks = new List(); + + foreach (string entry in data.Split(';')) + { + if (string.IsNullOrEmpty(entry)) continue; + string[] split = entry.Split(','); + if (split.Length != 7) continue; + + string resourceName = split[0]; + if (!double.TryParse(split[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double boilingPointK)) continue; + if (!double.TryParse(split[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double tankAreaM2)) continue; + if (!double.TryParse(split[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double conductWPerK)) continue; + if (!int.TryParse(split[4], out int isDewarInt)) continue; + if (!double.TryParse(split[5], NumberStyles.Float, CultureInfo.InvariantCulture, out double hsp)) continue; + if (!double.TryParse(split[6], NumberStyles.Float, CultureInfo.InvariantCulture, out double structThermalMassKJ)) continue; + + PartResourceDefinition resDef = PartResourceLibrary.Instance.GetDefinition(resourceName); + if (resDef == null || resDef.density <= 0d) continue; + if (!MFSSettings.resourceVsps.TryGetValue(resourceName, out double vsp) || vsp <= 0) continue; + + tanks.Add(new BgTankEntry + { + Name = resourceName, + Vsp = vsp, + Density = resDef.density, + BoilingPointK = boilingPointK, + TankAreaM2 = tankAreaM2, + ConductWPerK = conductWPerK, + IsDewar = isDewarInt != 0, + Hsp = hsp, + StructThermalMassKJ = structThermalMassKJ, + }); + } + + double coolerInputKW = 0d, coolerFrac = 0d, coolerLowestTempK = 0d; + if (!string.IsNullOrEmpty(coolerData)) + { + string[] cSplit = coolerData.Split(','); + if (cSplit.Length == 3 + && double.TryParse(cSplit[0], NumberStyles.Float, CultureInfo.InvariantCulture, out coolerInputKW) + && double.TryParse(cSplit[1], NumberStyles.Float, CultureInfo.InvariantCulture, out coolerFrac) + && double.TryParse(cSplit[2], NumberStyles.Float, CultureInfo.InvariantCulture, out coolerLowestTempK)) + { + // ok + } + else + { + coolerInputKW = 0d; coolerFrac = 0d; coolerLowestTempK = 0d; + } + } + + return new BgBoiloffCache(mliLayers, tanks.ToArray(), coolerInputKW, coolerFrac, coolerLowestTempK); + } + + internal void InitTemps(ProtoPartModuleSnapshot proto_module) + { + // Seed InternalTemps from the persisted TANK nodes + var tempLookup = new Dictionary(StringComparer.Ordinal); + foreach (ConfigNode tankNode in proto_module.moduleValues.GetNodes("TANK")) + { + string tName = tankNode.GetValue("name"); + string sVal = tankNode.GetValue("internalTemp"); + if (tName != null && double.TryParse(sVal, NumberStyles.Float, CultureInfo.InvariantCulture, out double t)) + tempLookup[tName] = t; + } + + for (int i = 0; i < Tanks.Length; i++) + { + InternalTemps[i] = tempLookup.TryGetValue(Tanks[i].Name, out double v) && v > 0 + ? v : Tanks[i].BoilingPointK; + } } } diff --git a/Source/Tanks/MFSSettings.cs b/Source/Tanks/MFSSettings.cs index e58786d5..3bad20c4 100644 --- a/Source/Tanks/MFSSettings.cs +++ b/Source/Tanks/MFSSettings.cs @@ -14,7 +14,6 @@ public class MFSSettings public static bool partUtilizationTweakable = false; public static string unitLabel = "u"; public static bool basemassUseTotalVolume = false; - public static double radiatorMinTempMult = 0.99d; // Move all possible tank upgrades into the preview list in OnStart // It requires an external mod to be responsible for calling the Validate() method. @@ -99,7 +98,6 @@ public static void ModuleManagerPostLoad() node.TryGetValue("partUtilizationTweakable", ref partUtilizationTweakable); node.TryGetValue("unitLabel", ref unitLabel); node.TryGetValue("basemassUseTotalVolume", ref basemassUseTotalVolume); - node.TryGetValue("radiatorMinTempMult", ref radiatorMinTempMult); node.TryGetValue("previewAllLockedTypes", ref previewAllLockedTypes); ignoreFuelsForFill.Clear(); diff --git a/Source/Tanks/ModuleFuelTanksRF.cs b/Source/Tanks/ModuleFuelTanksRF.cs index 98258f3a..a9af9cec 100644 --- a/Source/Tanks/ModuleFuelTanksRF.cs +++ b/Source/Tanks/ModuleFuelTanksRF.cs @@ -1,4 +1,5 @@ using KSP.Localization; +using ROUtils; using System; using System.Collections.Generic; using System.Globalization; @@ -51,6 +52,11 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr [KSPField(isPersistant = true)] public double bgBoiloffLastUpdate = 0d; + // Cryocooler params captured at save time for unloaded BackgroundUpdate use. + // Format: "coolerInputKW,coolerFracAtLowestTemp,lowestTempK" + [KSPField(isPersistant = true)] + public string bgCoolerData = ""; + [KSPField] public int maxMLILayers = 10; @@ -60,17 +66,57 @@ public partial class ModuleFuelTanks : IAnalyticTemperatureModifier, IAnalyticPr [KSPField] public float MLIArealDensity = 0.000015f; + [KSPField] + public HermiteCurve cryoCoolerEfficiency = new HermiteCurve(); + + [KSPField] + public float maxCoolerInputKW = 0f; + + [KSPField] + public float coolerBaseMass = 0f; // tonnes, fixed overhead (electronics, housing) + + [KSPField] + public HermiteCurve coolerMassPerKWInput = new HermiteCurve(); // tonnes/kW, keyed to T_cold (K) + + [KSPField] + public float coolerBaseCost = 0f; + + [KSPField] + public HermiteCurve coolerCostPerKWInput = new HermiteCurve(); // funds/kW, keyed to T_cold (K) + + [KSPField(isPersistant = true, guiActiveEditor = true, + guiName = "#RF_FuelTankRF_CryoCoolerInputPower", guiUnits = " kW", guiFormat = "F2", + groupName = CryogenicGroupName, groupDisplayName = CryogenicGroupName), + UI_FloatEdit(minValue = 0f, maxValue = 0f, incrementLarge = 1f, incrementSmall = 0.1f, incrementSlide = 0.01f, sigFigs = 2, + unit = " kW", scene = UI_Scene.Editor)] + public float coolerInputKW = 0f; + + [KSPField(guiName = "#RF_FuelTankRF_CryoCoolerLift", groupName = CryogenicGroupName)] + public string sCoolerLift; + + [KSPField(guiName = "#RF_FuelTankRF_CryoCoolerDraw", groupName = CryogenicGroupName)] + public string sCoolerDraw; + + [KSPField(guiName = "#RF_FuelTankRF_CryoCoolerCOP", groupName = CryogenicGroupName)] + public string sCoolerCOP; // Coefficient of Performance, ratio of the useful cooling provided to the work (energy) required + private double analyticSkinTemp; private readonly Dictionary boiloffProducts = new Dictionary(); public int numberOfMLILayers = 0; // base number of layers taken from TANK_DEFINITION configs + private double currentCoolerLiftKW; + private double currentCoolerDrawKW; + private double currentCoolerCOP; + private double boiloffMassT = 0d; public double BoiloffMassRate => boiloffMassT; private readonly List cryoTanks = new List(); // anything with maxAmount > 0 && vsp > 0 private readonly List lossInfo = new List(); private readonly List fluxInfo = new List(); + private readonly Dictionary _perTankFlux = new Dictionary(); + private readonly Dictionary _perTankLift = new Dictionary(); // Pre-parsed tank boiloff data for background processing. private static readonly ConditionalWeakTable _bgCache @@ -84,8 +130,9 @@ private static readonly ConditionalWeakTable RFSettings.Instance.globalConductionCompensation ? Math.Max(1.0d, PhysicsGlobals.ConductionFactor) : 1d; public bool SupportsBoiloff => cryoTanks.Count > 0; + public bool SupportCryoCooler => maxCoolerInputKW > 0f; + public bool HasCryoCooler => coolerInputKW > 0f; private bool IsProcedural => part.Modules.Contains("SSTUModularPart") || part.Modules.Contains("WingProcedural"); partial void OnLoadRF(ConfigNode _) { } @@ -113,7 +160,7 @@ partial void OnAwakeRF() partial void OnSaveRF(ConfigNode _) { - if (!HighLogic.LoadedSceneIsFlight || cryoTanks.Count == 0) + if (!HighLogic.LoadedSceneIsFlight || !SupportsBoiloff) return; double structuralThermalMass = ComputeStructuralThermalMass(); @@ -140,6 +187,21 @@ partial void OnSaveRF(ConfigNode _) } bgBoiloffData = entries.Count > 0 ? string.Join(";", entries) : ""; + + bgCoolerData = ""; + if (HasCryoCooler) + { + CalculateLowestTankTemperature(); + if (lowestTankTemperature > 0d && lowestTankTemperature < 300d) + { + double frac = cryoCoolerEfficiency.Evaluate(lowestTankTemperature); + if (frac > 0d) + { + bgCoolerData = string.Format(CultureInfo.InvariantCulture, "{0:R},{1:R},{2:R}", + coolerInputKW, frac, lowestTankTemperature); + } + } + } } partial void OnStartRF(StartState _) @@ -169,6 +231,18 @@ partial void OnStartRF(StartState _) massDirty = true; CalculateMass(); }; + + Fields[nameof(coolerInputKW)].guiActiveEditor = SupportCryoCooler; + if (SupportCryoCooler) + { + coolerInputKW = Mathf.Clamp(coolerInputKW, 0f, maxCoolerInputKW); + ((UI_FloatEdit)Fields[nameof(coolerInputKW)].uiControlEditor).maxValue = maxCoolerInputKW; + Fields[nameof(coolerInputKW)].uiControlEditor.onFieldChanged = delegate (BaseField field, object value) + { + massDirty = true; + CalculateMass(); + }; + } } bool debugBoilActive = SupportsBoiloff && (RFSettings.Instance.debugBoilOff || RFSettings.Instance.debugBoilOffPAW); @@ -176,6 +250,11 @@ partial void OnStartRF(StartState _) Fields[nameof(sHeatPenetration)].guiActive = debugBoilActive; Fields[nameof(sBoiloffLoss)].guiActive = debugBoilActive; + bool coolerFlightActive = HighLogic.LoadedSceneIsFlight && HasCryoCooler; + Fields[nameof(sCoolerLift)].guiActive = coolerFlightActive; + Fields[nameof(sCoolerDraw)].guiActive = coolerFlightActive; + Fields[nameof(sCoolerCOP)].guiActive = coolerFlightActive; + GameEvents.onPartResourceListChange.Add(OnPartResourceListChange); GameEvents.onPartDestroyed.Add(OnPartDestroyed); } @@ -183,6 +262,8 @@ partial void OnStartRF(StartState _) partial void CalculateMassRF(ref double mass) { mass += MLIArealDensity * totalTankArea * totalMLILayers; + if (HasCryoCooler) + mass += coolerBaseMass + coolerMassPerKWInput.Evaluate(GetCoolerTargetTemp()) * coolerInputKW; } partial void GetModuleCostRF(ref double cost) @@ -190,12 +271,16 @@ partial void GetModuleCostRF(ref double cost) // Estimate material cost at 0.10764/m2 treating as Fund = $1000 (for RO purposes) // Plus another 0.1 for installation cost += MLIArealCost * totalTankArea * totalMLILayers; + if (HasCryoCooler) + cost += coolerBaseCost + coolerCostPerKWInput.Evaluate(GetCoolerTargetTemp()) * coolerInputKW; } partial void UpdateRF() { - if (HighLogic.LoadedSceneIsFlight && (RFSettings.Instance.debugBoilOff || RFSettings.Instance.debugBoilOffPAW) && SupportsBoiloff && - UIPartActionController.Instance.GetItem(part) != null) + if (!HighLogic.LoadedSceneIsFlight) return; + if (UIPartActionController.Instance.GetItem(part) == null) return; + + if ((RFSettings.Instance.debugBoilOff || RFSettings.Instance.debugBoilOffPAW) && SupportsBoiloff) { sWallTemp = ""; foreach (var tank in cryoTanks) @@ -217,17 +302,37 @@ partial void UpdateRF() if (!string.IsNullOrEmpty(sHeatPenetration)) sHeatPenetration = sHeatPenetration.Remove(sHeatPenetration.Length - 3); } + + if (HasCryoCooler) + { + sCoolerLift = Utilities.FormatFlux(currentCoolerLiftKW); + sCoolerDraw = Utilities.FormatFlux(currentCoolerDrawKW); + sCoolerCOP = currentCoolerCOP > 0d ? currentCoolerCOP.ToString("F3") : "—"; + } } public void FixedUpdate() { if (HighLogic.LoadedSceneIsFlight && FlightGlobals.ready) { + // For analytic case boiloff will run though SetAnalyticTemperature() if (!_flightIntegrator.isAnalytical && SupportsBoiloff) ProcessBoiloff(_flightIntegrator.timeSinceLastUpdate); } } + private double GetCoolerTargetTemp() + { + double lowest = 300d; + foreach (FuelTank tank in tanksDict.Values) + { + if (tank.amount > 0 && tank.vsp > 0 && tank.temperature < lowest) + lowest = tank.temperature; + } + + return lowest; + } + /// /// Returns incoming heat flux in W for a single tank from the part skin, /// based on tank type and whether the part has MLI. @@ -287,10 +392,10 @@ private void ProcessBoiloff(double deltaTime, bool analyticalMode = false) boiloffMassT = 0d; lossInfo.Clear(); fluxInfo.Clear(); + _perTankFlux.Clear(); + _perTankLift.Clear(); bool hasCryoFuels = CalculateLowestTankTemperature(); - if (hasCryoFuels && MFSSettings.radiatorMinTempMult >= 0d) - part.radiatorMax = lowestTankTemperature * MFSSettings.radiatorMinTempMult / part.maxTemp; if (fueledByLaunchClamp) { @@ -300,6 +405,9 @@ private void ProcessBoiloff(double deltaTime, bool analyticalMode = false) tank.internalTemp = tank.temperature; } fueledByLaunchClamp = false; + currentCoolerLiftKW = 0d; + currentCoolerDrawKW = 0d; + currentCoolerCOP = 0d; Profiler.EndSample(); return; } @@ -313,29 +421,100 @@ private void ProcessBoiloff(double deltaTime, bool analyticalMode = false) // TODO: structuralThermalMass should be split up per-tank // TODO2: KSP will internally still assign a part.thermalMass value that includes resources double structuralThermalMass = ComputeStructuralThermalMass(); - double totalAbsorbedQ_kW = 0d; + // Pre-pass: compute per-tank incoming flux and tally what the cryocooler could usefully lift. + // Only at-boiling cryo tanks are coolable; sub-boiling tanks are left to warm normally + // (no subcooling), and non-cryo tanks aren't cooled. + double totalCoolableKW = 0d; foreach (FuelTank tank in tanksDict.Values) { - totalAbsorbedQ_kW += CalculateBoiloffForTank(tank, deltaTime, skinTemp, structuralThermalMass); + double q = GetIncomingFlux(skinTemp, tank) * 0.001d; + _perTankFlux[tank] = q; + if (tank.vsp > 0 && tank.amount > 0 && q > 0 && tank.internalTemp >= tank.temperature) + totalCoolableKW += q; } - // Feed the absorbed heat back as a skin heat sink. AddSkinThermalFlux takes kW and - // multiplies by TimeWarp.fixedDeltaTime internally, so passing the rate is correct here. - // Skipped in analytic mode: should only have a very small effect. - if (!analyticalMode && totalAbsorbedQ_kW != 0) - part.AddSkinThermalFlux(-totalAbsorbedQ_kW); + double totalLiftKW = 0d, totalInputKW = 0d; + if (hasCryoFuels) + ApplyCryocooling(deltaTime, skinTemp, totalCoolableKW, out totalLiftKW, out totalInputKW); + + double totalAbsorbedQ_kW = 0d; + foreach (FuelTank tank in tanksDict.Values) + { + double qIn = _perTankFlux.TryGetValue(tank, out double qv) ? qv : 0d; + double lift = _perTankLift.TryGetValue(tank, out double lv) ? lv : 0d; + totalAbsorbedQ_kW += CalculateBoiloffForTank(tank, qIn - lift, deltaTime, structuralThermalMass); + } + + currentCoolerLiftKW = totalLiftKW; + currentCoolerDrawKW = totalInputKW; + currentCoolerCOP = totalInputKW > 0d ? totalLiftKW / totalInputKW : 0d; + + // Skin energy balance: + // skin → tanks: -totalAbsorbedQ_kW (net flux absorbed by tanks, after any cooling) + // tanks → skin via cooler: +totalLiftKW (heat pumped out of tanks) + // EC → skin via cooler: +totalInputKW (electrical work turns into heat in the warm end) + // AddSkinThermalFlux takes kW and multiplies by TimeWarp.fixedDeltaTime internally, + // so passing the rate is correct here. Skipped in analytic mode. + if (!analyticalMode) + { + double skinFlux = -totalAbsorbedQ_kW + totalLiftKW + totalInputKW; + if (skinFlux != 0d) + part.AddSkinThermalFlux(skinFlux); + } Profiler.EndSample(); } - private double CalculateBoiloffForTank(FuelTank tank, double deltaTime, double skinTemp, double structuralThermalMass) + private void ApplyCryocooling(double deltaTime, double skinTemp, double totalCoolableKW, out double totalLiftKW, out double totalInputKW) + { + // Cryocooler allocation: COP = (T_cold / (T_hot - T_cold)) * fraction-of-Carnot curve + totalLiftKW = 0d; + totalInputKW = 0d; + if (HasCryoCooler && totalCoolableKW > 0d && skinTemp > lowestTankTemperature) + { + double carnot = lowestTankTemperature / (skinTemp - lowestTankTemperature); + double frac = cryoCoolerEfficiency.Evaluate(lowestTankTemperature); + double cop = Math.Max(0d, carnot * frac); + if (cop > 0d) + { + double maxLiftKW = coolerInputKW * cop; + double wantedLiftKW = Math.Min(maxLiftKW, totalCoolableKW); + double wantedInputKW = wantedLiftKW / cop; + double requestedEC = wantedInputKW * deltaTime; + double receivedEC = requestedEC > 0d + ? part.RequestResource("ElectricCharge", requestedEC, ResourceFlowMode.ALL_VESSEL) + : 0d; + + double scale = requestedEC > 0d ? receivedEC / requestedEC : 0d; + if (scale < 0d) scale = 0d; + else if (scale > 1d) scale = 1d; + + totalLiftKW = wantedLiftKW * scale; + totalInputKW = wantedInputKW * scale; + + if (totalLiftKW > 0d) + { + double liftFrac = totalLiftKW / totalCoolableKW; + foreach (FuelTank tank in cryoTanks) + { + if (tank.amount > 0 && tank.vsp > 0 + && tank.internalTemp >= tank.temperature + && _perTankFlux.TryGetValue(tank, out double q) && q > 0) + { + _perTankLift[tank] = q * liftFrac; + } + } + } + } + } + } + + private double CalculateBoiloffForTank(FuelTank tank, double Q_kW, double deltaTime, double structuralThermalMass) { if (tank.totalArea <= 0 || tank.tankRatio <= 0) return 0d; - double Q_kW = GetIncomingFlux(skinTemp, tank) * 0.001d; - double thermalMass = structuralThermalMass * tank.tankRatio + tank.amount * tank.density * tank.hsp; thermalMass = Math.Max(thermalMass, 1.0); @@ -348,6 +527,7 @@ private double CalculateBoiloffForTank(FuelTank tank, double deltaTime, double s // At high warp the large deltaTime can push internalTemp past skinTemp in a // single step; the resulting oscillation with asymmetric flux handling bleeds // energy from the skin each cycle, eventually driving it to 0 K (or infinity). + double skinTemp = part.skinTemperature; double prevTemp = tank.internalTemp; double newTemp = prevTemp + Q_kW * deltaTime / thermalMass; newTemp = Q_kW >= 0 ? Math.Min(newTemp, skinTemp) : Math.Max(newTemp, skinTemp); @@ -611,73 +791,108 @@ public static string BackgroundUpdate( List> resourceChangeRequest, double elapsed_s) { - string data = proto_module.moduleValues.GetValue(nameof(bgBoiloffData)); - if (string.IsNullOrEmpty(data)) return string.Empty; bool hasGeometry = KerbalismInterface.TryGetThermalData(vessel, out double vesselTemp, out _); if (!hasGeometry) return string.Empty; - if (!_bgCache.TryGetValue(proto_module, out BgBoiloffCache cache) || cache.DataVersion != data) + if (!_bgCache.TryGetValue(proto_module, out BgBoiloffCache cache)) { + string data = proto_module.moduleValues.GetValue(nameof(bgBoiloffData)); + if (string.IsNullOrEmpty(data)) return string.Empty; + + string coolerData = proto_module.moduleValues.GetValue(nameof(bgCoolerData)) ?? ""; int.TryParse(proto_module.moduleValues.GetValue(nameof(totalMLILayers)), out int mliLayers); - cache = BuildBgBoiloffCache(data, mliLayers); + cache = BgBoiloffCache.Build(data, coolerData, mliLayers); + cache.InitTemps(proto_module); + _bgCache.Remove(proto_module); _bgCache.Add(proto_module, cache); } - // On first use of a cache instance, seed InternalTemps from the persisted TANK nodes - bool needsInit = false; + double totalCoolableKW = ProcessBackgroundHeatLeakage(vesselTemp, cache); + double liftFrac = ProcessBackgroundCryocooling(availableResources, resourceChangeRequest, elapsed_s, vesselTemp, cache, totalCoolableKW); + bool anyBoiloff = ApplyBackgroundTankFlux(availableResources, resourceChangeRequest, elapsed_s, cache, liftFrac); + PersistBackgroundTankTemps(proto_module, cache); + + proto_module.moduleValues.SetValue("bgBoiloffLastUpdate", + Planetarium.GetUniversalTime().ToString(CultureInfo.InvariantCulture)); + + return anyBoiloff ? Localizer.GetStringByTag("#RF_FuelTankRF_kerbalismtips") : string.Empty; + } + + /// + /// compute per-tank Q_kW and tally coolable demand + /// + /// + /// + /// + private static double ProcessBackgroundHeatLeakage(double vesselTemp, BgBoiloffCache cache) + { + double totalCoolableKW = 0d; for (int i = 0; i < cache.Tanks.Length; i++) { - if (cache.InternalTemps[i] < 0) - { - needsInit = true; - break; - } + BgTankEntry entry = cache.Tanks[i]; + double internalTemp = cache.InternalTemps[i]; + double q; + if (entry.IsDewar) + q = GetDewarTransferRate(vesselTemp, internalTemp, entry.TankAreaM2) * 0.001; + else if (cache.MliLayers > 0 && entry.TankAreaM2 > 0) + q = GetMLITransferRate(vesselTemp, internalTemp, cache.MliLayers) * entry.TankAreaM2 * 0.001; + else if (entry.ConductWPerK > 0) + q = entry.ConductWPerK * (vesselTemp - internalTemp) * 0.001; + else + q = 0d; + + cache.FluxScratch[i] = q; + if (q > 0 && internalTemp >= entry.BoilingPointK) + totalCoolableKW += q; } - if (needsInit) + return totalCoolableKW; + } + + private static double ProcessBackgroundCryocooling(Dictionary availableResources, List> resourceChangeRequest, double elapsed_s, double vesselTemp, BgBoiloffCache cache, double totalCoolableKW) + { + double liftFrac = 0d; + if (cache.CoolerInputKW > 0d && cache.CoolerFrac > 0d && totalCoolableKW > 0d + && vesselTemp > cache.CoolerLowestTempK) { - var tempLookup = new Dictionary(StringComparer.Ordinal); - foreach (ConfigNode tankNode in proto_module.moduleValues.GetNodes("TANK")) - { - string tName = tankNode.GetValue("name"); - string sVal = tankNode.GetValue("internalTemp"); - if (tName != null && double.TryParse(sVal, NumberStyles.Float, CultureInfo.InvariantCulture, out double t)) - tempLookup[tName] = t; - } - for (int i = 0; i < cache.Tanks.Length; i++) + double carnot = cache.CoolerLowestTempK / (vesselTemp - cache.CoolerLowestTempK); + double cop = carnot * cache.CoolerFrac; + if (cop > 0d) { - if (cache.InternalTemps[i] < 0) - cache.InternalTemps[i] = tempLookup.TryGetValue(cache.Tanks[i].Name, out double v) && v > 0 - ? v : cache.Tanks[i].BoilingPointK; + double maxLiftKW = cache.CoolerInputKW * cop; + double wantedLiftKW = Math.Min(maxLiftKW, totalCoolableKW); + double wantedInputKW = wantedLiftKW / cop; + double ecAvail = availableResources.TryGetValue("ElectricCharge", out double ev) ? ev : 0d; + double requestedEC = wantedInputKW * elapsed_s; + double scale = requestedEC > 0d ? Math.Min(1d, ecAvail / requestedEC) : 0d; + if (scale < 0d) scale = 0d; + double actualInputKW = wantedInputKW * scale; + double actualLiftKW = wantedLiftKW * scale; + if (actualInputKW > 0d) + resourceChangeRequest.Add(new KeyValuePair("ElectricCharge", -actualInputKW)); + if (actualLiftKW > 0d) + liftFrac = actualLiftKW / totalCoolableKW; } } + return liftFrac; + } + + private static bool ApplyBackgroundTankFlux(Dictionary availableResources, List> resourceChangeRequest, double elapsed_s, BgBoiloffCache cache, double liftFrac) + { bool anyRequest = false; for (int i = 0; i < cache.Tanks.Length; i++) { BgTankEntry entry = cache.Tanks[i]; double internalTemp = cache.InternalTemps[i]; + double Q_kW = cache.FluxScratch[i]; - double Q_kW; - if (entry.IsDewar) - { - Q_kW = GetDewarTransferRate(vesselTemp, internalTemp, entry.TankAreaM2) * 0.001; - if (Q_kW == 0) continue; - } - else if (cache.MliLayers > 0 && entry.TankAreaM2 > 0) - { - Q_kW = GetMLITransferRate(vesselTemp, internalTemp, cache.MliLayers) * entry.TankAreaM2 * 0.001; - if (Q_kW == 0) continue; - } - else if (entry.ConductWPerK > 0) - { - double deltaTemp = vesselTemp - internalTemp; - if (deltaTemp == 0) continue; - Q_kW = entry.ConductWPerK * deltaTemp * 0.001; - } - else continue; + if (Q_kW > 0d && internalTemp >= entry.BoilingPointK && liftFrac > 0d) + Q_kW -= Q_kW * liftFrac; + + if (Q_kW == 0d) continue; if (internalTemp < entry.BoilingPointK) { @@ -689,14 +904,18 @@ public static string BackgroundUpdate( } else if (Q_kW > 0) { - // At boiling point: all flux drives boiloff + // At boiling point: residual flux drives boiloff double rateKgS = Q_kW / entry.Vsp; resourceChangeRequest.Add(new KeyValuePair(entry.Name, -rateKgS / entry.Density)); anyRequest = true; } } - // Persist updated internalTemps so they survive cache invalidation and vessel reload + return anyRequest; + } + + private static void PersistBackgroundTankTemps(ProtoPartModuleSnapshot proto_module, BgBoiloffCache cache) + { foreach (ConfigNode tankNode in proto_module.moduleValues.GetNodes("TANK")) { string tName = tankNode.GetValue("name"); @@ -711,50 +930,6 @@ public static string BackgroundUpdate( } } } - - proto_module.moduleValues.SetValue("bgBoiloffLastUpdate", - Planetarium.GetUniversalTime().ToString(CultureInfo.InvariantCulture)); - - return anyRequest ? Localizer.GetStringByTag("#RF_FuelTankRF_kerbalismtips") : string.Empty; - } - - private static BgBoiloffCache BuildBgBoiloffCache(string data, int mliLayers) - { - var tanks = new List(); - - foreach (string entry in data.Split(';')) - { - if (string.IsNullOrEmpty(entry)) continue; - string[] split = entry.Split(','); - if (split.Length != 7) continue; - - string resourceName = split[0]; - if (!double.TryParse(split[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double boilingPointK)) continue; - if (!double.TryParse(split[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double tankAreaM2)) continue; - if (!double.TryParse(split[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double conductWPerK)) continue; - if (!int.TryParse(split[4], out int isDewarInt)) continue; - if (!double.TryParse(split[5], NumberStyles.Float, CultureInfo.InvariantCulture, out double hsp)) continue; - if (!double.TryParse(split[6], NumberStyles.Float, CultureInfo.InvariantCulture, out double structThermalMassKJ)) continue; - - PartResourceDefinition resDef = PartResourceLibrary.Instance.GetDefinition(resourceName); - if (resDef == null || resDef.density <= 0d) continue; - if (!MFSSettings.resourceVsps.TryGetValue(resourceName, out double vsp) || vsp <= 0) continue; - - tanks.Add(new BgTankEntry - { - Name = resourceName, - Vsp = vsp, - Density = resDef.density, - BoilingPointK = boilingPointK, - TankAreaM2 = tankAreaM2, - ConductWPerK = conductWPerK, - IsDewar = isDewarInt != 0, - Hsp = hsp, - StructThermalMassKJ = structThermalMassKJ, - }); - } - - return new BgBoiloffCache(data, mliLayers, tanks.ToArray()); } ///