diff --git a/Docs/Fulgora lightning model.md b/Docs/Fulgora lightning model.md new file mode 100644 index 00000000..93e54de0 --- /dev/null +++ b/Docs/Fulgora lightning model.md @@ -0,0 +1,26 @@ +This is the math used to calculate `requiredChargeMw` (called "_cap_" here) in `BuildAccumulatorView`. +The first two equations are the starting point, and the remainder are solving for _cap_. + +_chargeTime_ is per lightning strike. +_eff_ is the efficiency of the lightning attractor. + +$$chargeTime=\frac{1000MJ\times eff}{drain+cap+load}$$ +$$cap\times chargeTime\times numStrikes-load\times(stormTime-chargeTime\times numStrikes)=reqMj$$ +$$\frac{cap\times 1000MJ\times eff\times numStrikes}{drain+cap+load}-load\times\left(stormTime-\frac{1000MJ\times eff\times numStrikes}{drain+cap+load}\right)=reqMj$$ +$$\frac{cap\times 1000MJ\times eff\times numStrikes}{drain+cap+load}-load\times stormTime+\left(\frac{load\times 1000MJ\times eff\times numStrikes}{drain+cap+load}\right)=reqMj$$ +$$\frac{cap\times 1000MJ\times eff\times numStrikes-load\times stormTime\times(drain+cap+load)+load\times 1000MJ\times eff\times numStrikes}{drain+cap+load}=reqMj$$ +$$cap\times 1000MJ\times eff\times numStrikes-load\times stormTime\times(drain+cap+load)+load\times 1000MJ\times eff\times numStrikes\\ +=reqMj\times(drain+cap+load)$$ +$$cap\times 1000MJ\times eff\times numStrikes-load\times stormTime\times drain-load\times stormTime\times cap\\ +-\ load\times stormTime\times load+load\times 1000MJ\times eff\times numStrikes\\ +=reqMj\times drain+reqMj\times cap+reqMj\times load$$ +$$cap\times 1000MJ\times eff\times numStrikes-load\times stormTime\times cap-reqMj\times cap\\ +\begin{aligned} +=\ &reqMj\times drain+reqMj\times load+load\times stormTime\times drain\\ +&+load\times stormTime\times load-load\times 1000MJ\times eff\times numStrikes +\end{aligned}$$ +$$\begin{aligned} +cap\times(&1000MJ\times eff\times numStrikes-load\times stormTime-reqMj)\\ +&=reqMj\times drain+load\times(reqMj+stormTime\times drain+stormTime\times load-1000MJ\times eff\times numStrikes) +\end{aligned}$$ +$$cap=\frac{reqMj\times drain+load\times(reqMj+stormTime\times(drain+load)-1000MJ\times eff\times numStrikes)}{1000MJ\times eff\times numStrikes-load\times stormTime-reqMj}$$ diff --git a/Yafc.Model/Data/DataClasses.cs b/Yafc.Model/Data/DataClasses.cs index d20aa247..ae8fd787 100644 --- a/Yafc.Model/Data/DataClasses.cs +++ b/Yafc.Model/Data/DataClasses.cs @@ -512,7 +512,7 @@ public class Entity : FactorioObject { public float mapGenDensity { get; internal set; } public float basePower { get; internal set; } public float Power(Quality quality) - => factorioType is "boiler" or "reactor" or "generator" or "burner-generator" ? quality.ApplyStandardBonus(basePower) + => factorioType is "boiler" or "reactor" or "generator" or "burner-generator" or "accumulator" ? quality.ApplyStandardBonus(basePower) : factorioType is "beacon" ? basePower * quality.BeaconConsumptionFactor : basePower; public EntityEnergy energy { get; internal set; } = null!; // TODO: Prove that this is always properly initialized. (Do we need an EntityWithEnergy type?) @@ -690,17 +690,36 @@ public override float baseCraftingSpeed { get => CraftingSpeed(Quality.Normal); internal set => throw new NotSupportedException("To set lightning attractor crafting speed, set the range and efficiency fields."); } + public float drain { get; internal set; } + internal float range; internal float efficiency; + /// + /// Gets the size of the (square) grid for this lightning attractor. The grid is sized to be as large as possible while protecting the + /// entire areas within the grid: the collection areas of diagonally-adjacent attractors should overlap as little as possible. + /// + public int ConstructionGrid(Quality quality) => (int)MathF.Floor((Range(quality) + LightningSearchRange) * MathF.Sqrt(2)); + public float Range(Quality quality) => quality.ApplyStandardBonus(range); + public float Efficiency(Quality quality) => quality.ApplyStandardBonus(efficiency); public override float CraftingSpeed(Quality quality) { // Maximum distance between attractors for full protection, in a square grid: - float distance = MathF.Floor((quality.ApplyStandardBonus(range) + LightningSearchRange) * MathF.Sqrt(2)); - float efficiency = quality.ApplyStandardBonus(this.efficiency); + float distance = ConstructionGrid(quality); + float efficiency = Efficiency(quality); // Production is coverage area times efficiency times lightning power density // Peak coverage area is (π*range²), but (distance²) allows full protection with a simple square grid. float area = distance * distance; - return area * efficiency * MwPerTile; + // Assume 90% of the captured energy is lost to the attractor's internal drain. + return area * efficiency * MwPerTile / 10; + } + + public float StormPotentialPerTick(Quality quality) { + // Maximum distance between attractors for full protection, in a square grid: + float distance = ConstructionGrid(quality); + // Storm potential is coverage area times lightning power density / .3 + // Peak coverage area is (π*range²), but (distance²) allows full protection with a simple square grid. + float area = distance * distance; + return area * MwPerTile / 60 / 0.3f; } } @@ -863,7 +882,8 @@ public class EntityInserter : Entity { } public class EntityAccumulator : Entity { - public float baseAccumulatorCapacity { get; internal set; } + internal float baseAccumulatorCapacity { get; set; } + internal float baseAccumulatorCurrent; public float AccumulatorCapacity(Quality quality) => quality.ApplyAccumulatorCapacityBonus(baseAccumulatorCapacity); } diff --git a/Yafc.Model/Model/QualityExtensions.cs b/Yafc.Model/Model/QualityExtensions.cs index 6952df15..cb4a9608 100644 --- a/Yafc.Model/Model/QualityExtensions.cs +++ b/Yafc.Model/Model/QualityExtensions.cs @@ -1,4 +1,6 @@ -namespace Yafc.Model; +using System.Diagnostics.CodeAnalysis; + +namespace Yafc.Model; public static class QualityExtensions { public static float GetCraftingSpeed(this IObjectWithQuality crafter) => crafter.target.CraftingSpeed(crafter.quality); @@ -6,4 +8,28 @@ public static class QualityExtensions { public static float GetPower(this IObjectWithQuality entity) => entity.target.Power(entity.quality); public static float GetBeaconEfficiency(this IObjectWithQuality beacon) => beacon.target.BeaconEfficiency(beacon.quality); + + public static float GetAttractorEfficiency(this IObjectWithQuality attractor) + => attractor.target.Efficiency(attractor.quality); + + public static float StormPotentialPerTick(this IObjectWithQuality attractor) + => attractor.target.StormPotentialPerTick(attractor.quality); + + /// + /// If possible, converts an into one with a different generic parameter. + /// + /// The desired type parameter for the output . + /// The input to be converted. + /// If ?.target is , an with + /// the same target and quality as . Otherwise, . + /// if the conversion was successful, or if it was not. + public static bool Is(this IObjectWithQuality? obj, [NotNullWhen(true)] out IObjectWithQuality? result) where T : FactorioObject { + if (obj is null or IObjectWithQuality) { + result = obj as IObjectWithQuality; + return result is not null; + } + // Use the conversion because it permits a null target. The constructor does not. + result = (ObjectWithQuality?)(obj.target as T, obj.quality); + return result is not null; + } } diff --git a/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs b/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs index 9da4c965..c5a292e7 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs @@ -9,7 +9,7 @@ namespace Yafc.Parser; internal partial class FactorioDataDeserializer { private const float EstimationDistanceFromCenter = 3000f; - private bool GetFluidBoxFilter(LuaTable table, string fluidBoxName, int temperature, [NotNullWhen(true)] out Fluid? fluid, out TemperatureRange range) { + private bool GetFluidBoxFilter(LuaTable? table, string fluidBoxName, int temperature, [NotNullWhen(true)] out Fluid? fluid, out TemperatureRange range) { fluid = null; range = default; @@ -40,7 +40,7 @@ private static int CountFluidBoxes(LuaTable list, bool input) { return count; } - private void ReadFluidEnergySource(LuaTable energySource, Entity entity) { + private void ReadFluidEnergySource(LuaTable? energySource, Entity entity) { var energy = entity.energy; _ = energySource.Get("burns_fluid", out bool burns, false); energy.type = burns ? EntityEnergyType.FluidFuel : EntityEnergyType.FluidHeat; @@ -69,7 +69,7 @@ private void ReadFluidEnergySource(LuaTable energySource, Entity entity) { } } - private void ReadEnergySource(LuaTable energySource, Entity entity, float defaultDrain = 0f) { + private void ReadEnergySource(LuaTable? energySource, Entity entity, float defaultDrain = 0f) { _ = energySource.Get("type", out string type, "burner"); if (type == "void") { @@ -199,8 +199,13 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { case "accumulator": var accumulator = GetObject(table); - if (table.Get("energy_source", out LuaTable? accumulatorEnergy) && accumulatorEnergy.Get("buffer_capacity", out string? capacity)) { - accumulator.baseAccumulatorCapacity = ParseEnergy(capacity); + if (table.Get("energy_source", out LuaTable? accumulatorEnergy)) { + if (accumulatorEnergy.Get("buffer_capacity", out string? capacity)) { + accumulator.baseAccumulatorCapacity = ParseEnergy(capacity); + } + if (accumulatorEnergy.Get("input_flow_limit", out string? inputPower)) { + accumulator.basePower = ParseEnergy(inputPower); + } } break; case "agricultural-tower": @@ -425,6 +430,9 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { attractor.energy = voidEntityEnergy; attractor.range = range; attractor.efficiency = efficiency; + if (table.Get("energy_source", out LuaTable? energy) && energy.Get("drain", out string? drain)) { + attractor.drain = ParseEnergy(drain) * 60; // Drain is listed as MJ/tick, not MW + } recipeCrafters.Add(attractor, SpecialNames.GeneratorRecipe); } break; diff --git a/Yafc/Widgets/ObjectTooltip.cs b/Yafc/Widgets/ObjectTooltip.cs index dea17bed..3474efc0 100644 --- a/Yafc/Widgets/ObjectTooltip.cs +++ b/Yafc/Widgets/ObjectTooltip.cs @@ -331,18 +331,26 @@ private void BuildEntity(Entity entity, Quality quality, ImGui gui) { break; case EntityAccumulator accumulator: miscText = "Accumulator charge: " + DataUtils.FormatAmount(accumulator.AccumulatorCapacity(quality), UnitOfMeasure.Megajoule); + break; + case EntityAttractor attractor: + if (attractor.baseCraftingSpeed > 0f) { + miscText = "Power production (average usable): " + DataUtils.FormatAmount(attractor.CraftingSpeed(quality), UnitOfMeasure.Megawatt); + miscText += $"\n Build in a {attractor.ConstructionGrid(quality)}-tile square grid"; + miscText += "\nProtection range: " + DataUtils.FormatAmount(attractor.Range(quality), UnitOfMeasure.None); + miscText += "\nCollection efficiency: " + DataUtils.FormatAmount(attractor.Efficiency(quality), UnitOfMeasure.Percent); + } + break; case EntityCrafter solarPanel: - if (solarPanel.baseCraftingSpeed > 0f && entity.factorioType is "solar-panel" or "lightning-attractor") { + if (solarPanel.baseCraftingSpeed > 0f && entity.factorioType == "solar-panel") { miscText = "Power production (average): " + DataUtils.FormatAmount(solarPanel.CraftingSpeed(quality), UnitOfMeasure.Megawatt); } - break; } if (miscText != null) { using (gui.EnterGroup(contentPadding)) { - gui.BuildText(miscText); + gui.BuildText(miscText, TextBlockDisplayStyle.WrappedText); } } } diff --git a/Yafc/Workspace/ProductionTable/ProductionTableView.cs b/Yafc/Workspace/ProductionTable/ProductionTableView.cs index 84bc7871..199bede8 100644 --- a/Yafc/Workspace/ProductionTable/ProductionTableView.cs +++ b/Yafc/Workspace/ProductionTable/ProductionTableView.cs @@ -365,17 +365,63 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { view.BuildGoodsIcon(gui, fuel, fuelLink, fuelAmount, ProductDropdownType.Fuel, recipe, recipe.linkRoot, HintLocations.OnProducingRecipes); } else { - if (recipe.recipe == Database.electricityGeneration && recipe.entity.target.factorioType == "solar-panel") { - BuildSolarPanelAccumulatorView(gui, recipe); + if (recipe.recipe == Database.electricityGeneration && recipe.entity.target.factorioType is "solar-panel" or "lightning-attractor") { + BuildAccumulatorView(gui, recipe); } } } - private static void BuildSolarPanelAccumulatorView(ImGui gui, RecipeRow recipe) { + private static void BuildAccumulatorView(ImGui gui, RecipeRow recipe) { var accumulator = recipe.GetVariant(Database.allAccumulators); Quality accumulatorQuality = recipe.GetVariant(Database.qualities.all.OrderBy(q => q.level).ToArray()); - float requiredMj = recipe.entity?.GetCraftingSpeed() * recipe.buildingCount * (70 / 0.7f) ?? 0; // 70 seconds of charge time to last through the night - float requiredAccumulators = requiredMj / accumulator.AccumulatorCapacity(accumulatorQuality); + float requiredAccumulators = 0; + if (recipe.entity?.target.factorioType == "solar-panel") { + float requiredMj = recipe.entity?.GetCraftingSpeed() * recipe.buildingCount * (70 / 0.7f) ?? 0; // 70 seconds of charge time to last through the night + requiredAccumulators = requiredMj / accumulator.AccumulatorCapacity(accumulatorQuality); + } + else if (recipe.entity.Is(out IObjectWithQuality? attractor)) { + // Model the storm as rising from 0% to 100% over 30 seconds, staying at 100% for 24 seconds, and decaying over 30 seconds. + // I adjusted these until the right answers came out of my Excel model. + // TODO(multi-planet): Adjust these numbers based on day length. + const int stormRiseTicks = 30 * 60, stormPlateauTicks = 24 * 60, stormFallTicks = 30 * 60; + const int stormTotalTicks = stormRiseTicks + stormPlateauTicks + stormFallTicks; + + // Don't try to model the storm with less than 1 attractor (6 lightning strikes for a normal rod) + float stormMjPerTick = attractor.StormPotentialPerTick() * (recipe.buildingCount < 1 ? 1 : recipe.buildingCount); + // TODO(multi-planet): Use the appropriate LightningPrototype::energy instead of hardcoding the 1000 of Fulgoran lightning. + // Tick numbers will be wrong if the first and last strike don't happen in the rise and fall periods. This is okay because + // a single normal rod has the first strike at 23 seconds, and the _second_ at 32. + float totalStormEnergy = stormMjPerTick * 3 * 60 * 60 /*TODO(multi-planet): ticks per day*/ * 0.3f; + float lostStormEnergy = totalStormEnergy % 1000; + float firstStrikeTick = (MathF.Sqrt(1 + 8 * 1000 * stormRiseTicks / stormMjPerTick) + 1) / 2; + float lastStrikeTick = stormTotalTicks - (MathF.Sqrt(1 + 8 * lostStormEnergy * stormFallTicks / stormMjPerTick) + 1) / 2; + int strikeCount = (int)(totalStormEnergy / 1000); + + float requiredPower = attractor.GetCraftingSpeed() * recipe.buildingCount; + + // Two different conditions need to be tested here. The first test is for capacity when discharging: the accumulators must have + // a capacity of requiredPower * timeBetween(lastStrikeDischarged, firstStrike + 1 day) + // As simplifying assumptions for this calculation, (1) the accumulators are fully charged when the last strike hits, and + // (2) the attractor's internal buffer is empty when the last strike hits. + // If incorrect, these cause errors in opposite directions. + float lastStrikeDrainedTick = lastStrikeTick + 1000 * attractor.GetAttractorEfficiency() / (requiredPower + attractor.target.drain) * 60; + float requiredTicks = 3 * 60 * 60 /*TODO(multi-planet): ticks per day*/ - lastStrikeDrainedTick + firstStrikeTick; + float requiredMj = requiredPower * requiredTicks / 60; + + // The second test is for capacity when charging: The accumulators must draw at least requiredMj out of the attractors. + // Solve: chargeTimePerStrike = 1000MJ * effectiveness / (150MW + chargePower + requiredPower) + // And: chargePower * chargeTimePerStrike * #strikes - requiredPower * nonStrikeStormTime = requiredMj + // Not fun (see Fulgora lightning model.md), but the result is: + float stormLengthSeconds = (lastStrikeDrainedTick - firstStrikeTick) / 60; + float stormEnergy = 1000 * attractor.GetAttractorEfficiency() * strikeCount; + float numerator = requiredMj * attractor.target.drain + requiredPower * (requiredMj + stormLengthSeconds * (attractor.target.drain + requiredPower) - stormEnergy); + float denominator = stormEnergy - requiredPower * stormLengthSeconds - requiredMj; + float requiredChargeMw = numerator / denominator; + + requiredAccumulators = Math.Max(requiredMj / accumulator.AccumulatorCapacity(accumulatorQuality), + requiredChargeMw / accumulator.Power(accumulatorQuality)); + } + ObjectWithQuality accumulatorWithQuality = new(accumulator, accumulatorQuality); if (gui.BuildFactorioObjectWithAmount(accumulatorWithQuality, requiredAccumulators, ButtonDisplayStyle.ProductionTableUnscaled) == Click.Left) { ShowAccumulatorDropdown(gui, recipe, accumulator, accumulatorQuality); @@ -1467,7 +1513,8 @@ protected override void BuildPageTooltip(ImGui gui, ProductionTable contents) { {WarningFlags.ExceedsBuiltCount, "This recipe requires more buildings than are currently built."}, {WarningFlags.AsteroidCollectionNotModelled, "The speed of asteroid collectors depends heavily on location and travel speed. " + "It also depends on the distance between adjacent collectors. These dependencies are not modeled. Expect widely varied performance."}, - {WarningFlags.AssumesFulgoraAndModel, "Energy production values assume Fulgoran storms and attractors in a square grid." }, + {WarningFlags.AssumesFulgoraAndModel, "Energy production values assume Fulgoran storms and attractors in a square grid.\n" + + "The accumulator estimate tries to store 10% of the energy captured by the attractors."}, }; private static readonly (Icon icon, SchemeColor color)[] tagIcons = [ diff --git a/changelog.txt b/changelog.txt index 1790f05e..8a303c4d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -15,6 +15,11 @@ // Internal changes: // Changes to the code that do not affect the behavior of the program. ---------------------------------------------------------------------------------------------------------------------- +Version: +Date: + Features: + - Detect lightning rods/collectors as electricity sources, and estimate the required accumulator count. +---------------------------------------------------------------------------------------------------------------------- Version: 2.7.0 Date: January 27th 2025 Features: diff --git a/exclusion.dic b/exclusion.dic index c3386afa..fb6645cc 100644 --- a/exclusion.dic +++ b/exclusion.dic @@ -6,6 +6,7 @@ dylib Factorio Floodfill foreach +Fulgoran imgui Kovarex liblua