Skip to content

Commit

Permalink
feat(lightning): Estimate required accumulators for lightning attract…
Browse files Browse the repository at this point in the history
…ors.
  • Loading branch information
DaleStan committed Jan 30, 2025
1 parent 79e9bb1 commit 9e89ab4
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 20 deletions.
26 changes: 26 additions & 0 deletions Docs/Fulgora lightning model.md
Original file line number Diff line number Diff line change
@@ -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}$$
30 changes: 25 additions & 5 deletions Yafc.Model/Data/DataClasses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Expand Down Expand Up @@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
}

Expand Down Expand Up @@ -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);
}

Expand Down
28 changes: 27 additions & 1 deletion Yafc.Model/Model/QualityExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
namespace Yafc.Model;
using System.Diagnostics.CodeAnalysis;

namespace Yafc.Model;

public static class QualityExtensions {
public static float GetCraftingSpeed(this IObjectWithQuality<EntityCrafter> crafter) => crafter.target.CraftingSpeed(crafter.quality);

public static float GetPower(this IObjectWithQuality<Entity> entity) => entity.target.Power(entity.quality);

public static float GetBeaconEfficiency(this IObjectWithQuality<EntityBeacon> beacon) => beacon.target.BeaconEfficiency(beacon.quality);

public static float GetAttractorEfficiency(this IObjectWithQuality<EntityAttractor> attractor)
=> attractor.target.Efficiency(attractor.quality);

public static float StormPotentialPerTick(this IObjectWithQuality<EntityAttractor> attractor)
=> attractor.target.StormPotentialPerTick(attractor.quality);

/// <summary>
/// If possible, converts an <see cref="IObjectWithQuality{T}"/> into one with a different generic parameter.
/// </summary>
/// <typeparam name="T">The desired type parameter for the output <see cref="IObjectWithQuality{T}"/>.</typeparam>
/// <param name="obj">The input <see cref="IObjectWithQuality{T}"/> to be converted.</param>
/// <param name="result">If <c><paramref name="obj"/>?.target is <typeparamref name="T"/></c>, an <see cref="IObjectWithQuality{T}"/> with
/// the same target and quality as <paramref name="obj"/>. Otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if the conversion was successful, or <see langword="false"/> if it was not.</returns>
public static bool Is<T>(this IObjectWithQuality<FactorioObject>? obj, [NotNullWhen(true)] out IObjectWithQuality<T>? result) where T : FactorioObject {
if (obj is null or IObjectWithQuality<T>) {
result = obj as IObjectWithQuality<T>;
return result is not null;
}
// Use the conversion because it permits a null target. The constructor does not.
result = (ObjectWithQuality<T>?)(obj.target as T, obj.quality);
return result is not null;
}
}
18 changes: 13 additions & 5 deletions Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -199,8 +199,13 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) {
case "accumulator":
var accumulator = GetObject<Entity, EntityAccumulator>(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":
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 11 additions & 3 deletions Yafc/Widgets/ObjectTooltip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
59 changes: 53 additions & 6 deletions Yafc/Workspace/ProductionTable/ProductionTableView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EntityAttractor>? 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<Entity> accumulatorWithQuality = new(accumulator, accumulatorQuality);
if (gui.BuildFactorioObjectWithAmount(accumulatorWithQuality, requiredAccumulators, ButtonDisplayStyle.ProductionTableUnscaled) == Click.Left) {
ShowAccumulatorDropdown(gui, recipe, accumulator, accumulatorQuality);
Expand Down Expand Up @@ -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 = [
Expand Down
5 changes: 5 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions exclusion.dic
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dylib
Factorio
Floodfill
foreach
Fulgoran
imgui
Kovarex
liblua
Expand Down

0 comments on commit 9e89ab4

Please sign in to comment.