Adds the thermo-electric generator (#18840)

* Basic TEG start.

Connects via node group.

* Basic TEG test map

* Sensor monitoring basics, TEG circulator flow

* Basic power generation (it doesn't work)

* More sensor monitoring work

* Battery (SMES) monitoring system.

* Sensor monitoring fixes

Make it work properly when mapped.

* Test map improvements

* Revise TEG power output mechanism.

Now uses a fixed supplier with a custom ramping system.

* TEG test map fixes

* Make air alarms and pumps open UI on activate.

* Clean up thermo machines power switch.

Removed separate Enabled bool from the component that always matched the power receiver's state.

This enables adding a PowerSwitch component to give us alt click/verb menu.

* TEG but now fancy

* Make sensor monitoring console obviously WiP to mappers.

* Vending machine sound, because of course.

* Terrible, terrible graph background color

* Examine improvements for the TEG.

* Account for electrical power when equalizing gas mixtures.

* Get rid of the TegCirculatorArrow logic.

Use TimedDespawn instead. The "no show in right-click menuu" goes into a new general-purpose component.

Thanks Julian.

* Put big notice of "not ready, here's why" on the sensor monitoring console.

* TryGetComponent -> TryComp

* Lol there's a HideContextMenu tag

* Test fixes

* Guidebook for TEG

Fixed rotation on GuideEntityEmbed not working correctly.

Added Margin property to GuideEntityEmbed

* Make TEG power bar default to invisible.

So it doesn't appear in the guidebook and spawn menu.
This commit is contained in:
Pieter-Jan Briers
2023-08-12 22:41:55 +02:00
committed by GitHub
parent 61bf951ec4
commit a242af506e
74 changed files with 5546 additions and 22 deletions

View File

@@ -0,0 +1,383 @@
using Content.Server.Atmos;
using Content.Server.Atmos.EntitySystems;
using Content.Server.Atmos.Piping.Components;
using Content.Server.Audio;
using Content.Server.DeviceNetwork;
using Content.Server.DeviceNetwork.Systems;
using Content.Server.NodeContainer;
using Content.Server.NodeContainer.Nodes;
using Content.Server.Power.Components;
using Content.Shared.Examine;
using Content.Shared.Power.Generation.Teg;
using Content.Shared.Rounding;
using Robust.Server.GameObjects;
namespace Content.Server.Power.Generation.Teg;
/// <summary>
/// Handles processing logic for the thermo-electric generator (TEG).
/// </summary>
/// <remarks>
/// <para>
/// The TEG generates power by exchanging heat between gases flowing through its two sides.
/// The gas flows through a "circulator" entity on each side, which have both an inlet and an outlet port.
/// </para>
/// <remarks>
/// Connecting the TEG core to its circulators is implemented via a node group. See <see cref="TegNodeGroup"/>.
/// </remarks>
/// <para>
/// The TEG center does HV power output, and must also be connected to an LV wire for the TEG to function.
/// </para>
/// <para>
/// Unlike in SS13, the TEG actually adjusts gas heat exchange to match the energy demand of the power network.
/// To achieve this, the TEG implements its own ramping logic instead of using the built-in Pow3r ramping.
/// The TEG actually has a maximum output of +n% more than was really generated,
/// which allows Pow3r to draw more power to "signal" that there is more network load.
/// The ramping is also exponential instead of linear like in normal Pow3r.
/// This system does mean a fully-loaded TEG creates +n% power out of thin air, but this is considered acceptable.
/// </para>
/// </remarks>
/// <seealso cref="TegGeneratorComponent"/>
/// <seealso cref="TegCirculatorComponent"/>
/// <seealso cref="TegNodeGroup"/>
/// <seealso cref="TegSensorData"/>
public sealed class TegSystem : EntitySystem
{
/// <summary>
/// Node name for the TEG part connection nodes (<see cref="TegNodeGroup"/>).
/// </summary>
private const string NodeNameTeg = "teg";
/// <summary>
/// Node name for the inlet pipe of a circulator.
/// </summary>
private const string NodeNameInlet = "inlet";
/// <summary>
/// Node name for the outlet pipe of a circulator.
/// </summary>
private const string NodeNameOutlet = "outlet";
/// <summary>
/// Device network command to have the TEG output a <see cref="TegSensorData"/> object for its last statistics.
/// </summary>
public const string DeviceNetworkCommandSyncData = "teg_sync_data";
[Dependency] private readonly AtmosphereSystem _atmosphere = default!;
[Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
[Dependency] private readonly AppearanceSystem _appearance = default!;
[Dependency] private readonly PointLightSystem _pointLight = default!;
[Dependency] private readonly AmbientSoundSystem _ambientSound = default!;
private EntityQuery<NodeContainerComponent> _nodeContainerQuery;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<TegGeneratorComponent, AtmosDeviceUpdateEvent>(GeneratorUpdate);
SubscribeLocalEvent<TegGeneratorComponent, PowerChangedEvent>(GeneratorPowerChange);
SubscribeLocalEvent<TegGeneratorComponent, DeviceNetworkPacketEvent>(DeviceNetworkPacketReceived);
SubscribeLocalEvent<TegGeneratorComponent, ExaminedEvent>(GeneratorExamined);
_nodeContainerQuery = GetEntityQuery<NodeContainerComponent>();
}
private void GeneratorExamined(EntityUid uid, TegGeneratorComponent component, ExaminedEvent args)
{
if (GetNodeGroup(uid) is not { IsFullyBuilt: true })
{
args.PushMarkup(Loc.GetString("teg-generator-examine-connection"));
}
else
{
var supplier = Comp<PowerSupplierComponent>(uid);
args.PushMarkup(Loc.GetString("teg-generator-examine-power", ("power", supplier.CurrentSupply)));
}
}
private void GeneratorUpdate(EntityUid uid, TegGeneratorComponent component, AtmosDeviceUpdateEvent args)
{
var tegGroup = GetNodeGroup(uid);
if (tegGroup is not { IsFullyBuilt: true })
return;
var supplier = Comp<PowerSupplierComponent>(uid);
var powerReceiver = Comp<ApcPowerReceiverComponent>(uid);
if (!powerReceiver.Powered)
{
supplier.MaxSupply = 0;
return;
}
var circA = tegGroup.CirculatorA!.Owner;
var circB = tegGroup.CirculatorB!.Owner;
var (inletA, outletA) = GetPipes(circA);
var (inletB, outletB) = GetPipes(circB);
var (airA, δpA) = GetCirculatorAirTransfer(inletA.Air, outletA.Air);
var (airB, δpB) = GetCirculatorAirTransfer(inletB.Air, outletB.Air);
var cA = _atmosphere.GetHeatCapacity(airA);
var cB = _atmosphere.GetHeatCapacity(airB);
// Shift ramp position based on demand and generation from previous tick.
var curRamp = component.RampPosition;
var lastDraw = supplier.CurrentSupply;
// Limit amount lost/gained based on power factor.
curRamp = MathHelper.Clamp(lastDraw, curRamp / component.RampFactor, curRamp * component.RampFactor);
curRamp = MathF.Max(curRamp, component.RampMinimum);
component.RampPosition = curRamp;
var electricalEnergy = 0f;
if (airA.Pressure > 0 && airB.Pressure > 0)
{
var hotA = airA.Temperature > airB.Temperature;
var cHot = hotA ? cA : cB;
// Calculate maximum amount of energy to generate this tick based on ramping above.
// This clamps the thermal energy transfer as well.
var targetEnergy = curRamp / _atmosphere.AtmosTickRate;
var transferMax = targetEnergy / (component.ThermalEfficiency * component.PowerFactor);
// Calculate thermal and electrical energy transfer between the two sides.
var δT = MathF.Abs(airA.Temperature - airB.Temperature);
var transfer = Math.Min(δT * cA * cB / (cA + cB - cHot * component.ThermalEfficiency), transferMax);
electricalEnergy = transfer * component.ThermalEfficiency * component.PowerFactor;
var outTransfer = transfer * (1 - component.ThermalEfficiency);
// Adjust thermal energy in transferred gas mixtures.
if (hotA)
{
// A -> B
airA.Temperature -= transfer / cA;
airB.Temperature += outTransfer / cB;
}
else
{
// B -> A
airA.Temperature += outTransfer / cA;
airB.Temperature -= transfer / cB;
}
}
component.LastGeneration = electricalEnergy;
// Turn energy (at atmos tick rate) into wattage.
var power = electricalEnergy * _atmosphere.AtmosTickRate;
// Add ramp factor. This magics slight power into existence, but allows us to ramp up.
supplier.MaxSupply = power * component.RampFactor;
var circAComp = Comp<TegCirculatorComponent>(circA);
var circBComp = Comp<TegCirculatorComponent>(circB);
circAComp.LastPressureDelta = δpA;
circAComp.LastMolesTransferred = airA.TotalMoles;
circBComp.LastPressureDelta = δpB;
circBComp.LastMolesTransferred = airB.TotalMoles;
_atmosphere.Merge(outletA.Air, airA);
_atmosphere.Merge(outletB.Air, airB);
UpdateAppearance(uid, component, powerReceiver, tegGroup);
}
private void UpdateAppearance(
EntityUid uid,
TegGeneratorComponent component,
ApcPowerReceiverComponent powerReceiver,
TegNodeGroup nodeGroup)
{
int powerLevel;
if (powerReceiver.Powered)
{
powerLevel = ContentHelpers.RoundToLevels(
component.RampPosition - component.RampMinimum,
component.MaxVisualPower - component.RampMinimum,
12);
}
else
{
powerLevel = 0;
}
_ambientSound.SetAmbience(uid, powerLevel >= 1);
// TODO: Ok so this introduces popping which is a major shame big rip.
// _ambientSound.SetVolume(uid, MathHelper.Lerp(component.VolumeMin, component.VolumeMax, MathHelper.Clamp01(component.RampPosition / component.MaxVisualPower)));
_appearance.SetData(uid, TegVisuals.PowerOutput, powerLevel);
if (nodeGroup.IsFullyBuilt)
{
UpdateCirculatorAppearance(nodeGroup.CirculatorA!.Owner, powerReceiver.Powered);
UpdateCirculatorAppearance(nodeGroup.CirculatorB!.Owner, powerReceiver.Powered);
}
}
[Access(typeof(TegNodeGroup))]
public void UpdateGeneratorConnectivity(
EntityUid uid,
TegNodeGroup group,
TegGeneratorComponent? component = null)
{
if (!Resolve(uid, ref component))
return;
var powerReceiver = Comp<ApcPowerReceiverComponent>(uid);
powerReceiver.PowerDisabled = !group.IsFullyBuilt;
UpdateAppearance(uid, component, powerReceiver, group);
}
[Access(typeof(TegNodeGroup))]
public void UpdateCirculatorConnectivity(
EntityUid uid,
TegNodeGroup group,
TegCirculatorComponent? component = null)
{
if (!Resolve(uid, ref component))
return;
// If the group IS fully built, the generator will update its circulators.
// Otherwise, make sure circulator is set to nothing.
if (!group.IsFullyBuilt)
{
UpdateCirculatorAppearance(uid, false);
}
}
private void UpdateCirculatorAppearance(EntityUid uid, bool powered)
{
var circ = Comp<TegCirculatorComponent>(uid);
TegCirculatorSpeed speed;
if (powered && circ.LastPressureDelta > 0 && circ.LastMolesTransferred > 0)
{
if (circ.LastPressureDelta > circ.VisualSpeedDelta)
speed = TegCirculatorSpeed.SpeedFast;
else
speed = TegCirculatorSpeed.SpeedSlow;
}
else
{
speed = TegCirculatorSpeed.SpeedStill;
}
_appearance.SetData(uid, TegVisuals.CirculatorSpeed, speed);
_appearance.SetData(uid, TegVisuals.CirculatorPower, powered);
if (TryComp(uid, out PointLightComponent? pointLight))
{
_pointLight.SetEnabled(uid, powered, pointLight);
pointLight.Color = speed == TegCirculatorSpeed.SpeedFast ? circ.LightColorFast : circ.LightColorSlow;
}
}
private void GeneratorPowerChange(EntityUid uid, TegGeneratorComponent component, ref PowerChangedEvent args)
{
var nodeGroup = GetNodeGroup(uid);
if (nodeGroup == null)
return;
UpdateAppearance(uid, component, Comp<ApcPowerReceiverComponent>(uid), nodeGroup);
}
/// <returns>Null if the node group is not yet available. This can happen during initialization.</returns>
private TegNodeGroup? GetNodeGroup(EntityUid uidGenerator)
{
NodeContainerComponent? nodeContainer = null;
if (!_nodeContainerQuery.Resolve(uidGenerator, ref nodeContainer))
return null;
if (!nodeContainer.Nodes.TryGetValue(NodeNameTeg, out var tegNode))
return null;
if (tegNode.NodeGroup is not TegNodeGroup tegGroup)
return null;
return tegGroup;
}
private static (GasMixture, float δp) GetCirculatorAirTransfer(GasMixture airInlet, GasMixture airOutlet)
{
var n1 = airInlet.TotalMoles;
var n2 = airOutlet.TotalMoles;
var p1 = airInlet.Pressure;
var p2 = airOutlet.Pressure;
var V1 = airInlet.Volume;
var V2 = airOutlet.Volume;
var T1 = airInlet.Temperature;
var T2 = airOutlet.Temperature;
var δp = p1 - p2;
var denom = T1 * V2 + T2 * V1;
if (δp > 0 && p1 > 0 && denom > 0)
{
var transferMoles = n1 - (n1 + n2) * T2 * V1 / denom;
return (airInlet.Remove(transferMoles), δp);
}
return (new GasMixture(), δp);
}
private (PipeNode inlet, PipeNode outlet) GetPipes(EntityUid uidCirculator)
{
var nodeContainer = _nodeContainerQuery.GetComponent(uidCirculator);
var inlet = (PipeNode) nodeContainer.Nodes[NodeNameInlet];
var outlet = (PipeNode) nodeContainer.Nodes[NodeNameOutlet];
return (inlet, outlet);
}
private void DeviceNetworkPacketReceived(
EntityUid uid,
TegGeneratorComponent component,
DeviceNetworkPacketEvent args)
{
if (!args.Data.TryGetValue(DeviceNetworkConstants.Command, out string? cmd))
return;
switch (cmd)
{
case DeviceNetworkCommandSyncData:
var group = GetNodeGroup(uid);
if (group is not { IsFullyBuilt: true })
return;
var supplier = Comp<PowerSupplierComponent>(uid);
var payload = new NetworkPayload
{
[DeviceNetworkConstants.Command] = DeviceNetworkCommandSyncData,
[DeviceNetworkCommandSyncData] = new TegSensorData
{
CirculatorA = GetCirculatorSensorData(group.CirculatorA!.Owner),
CirculatorB = GetCirculatorSensorData(group.CirculatorB!.Owner),
LastGeneration = component.LastGeneration,
PowerOutput = supplier.CurrentSupply,
RampPosition = component.RampPosition
}
};
_deviceNetwork.QueuePacket(uid, args.SenderAddress, payload);
break;
}
}
private TegSensorData.Circulator GetCirculatorSensorData(EntityUid circulator)
{
var (inlet, outlet) = GetPipes(circulator);
return new TegSensorData.Circulator(
inlet.Air.Pressure,
outlet.Air.Pressure,
inlet.Air.Temperature,
outlet.Air.Temperature);
}
}