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