The TEG used to limit hot-cold energy transfer based on actual power drawn, and had maximum efficiency at whatever temperature difference. This PR adjusts the hot-cold energy transfer to be uncapped, "venting" the excess heat that is not used to generate power, and adds an efficiency curve that limits efficiency at low thermal temperatures. People have been cheesing the TEG by hooking up the hot end to the CO2 miner (which produces infinite, room-temperature gas) and the cold end to a space radiator. With this change, you will actually need to set up a burn chamber in order to get appreciable power out of the TEG (see below). If you build a gas holding chamber, you will have to throttle the gas flowing into the TEG instead of constantly cycling the gas through over and over again.
400 lines
15 KiB
C#
400 lines
15 KiB
C#
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.Atmos;
|
|
using Content.Shared.DeviceNetwork;
|
|
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, ref 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, true);
|
|
var cB = _atmosphere.GetHeatCapacity(airB, true);
|
|
|
|
// Shift ramp position based on demand and generation from previous tick.
|
|
var curRamp = component.RampPosition;
|
|
var lastDraw = supplier.CurrentSupply;
|
|
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;
|
|
|
|
// Calculate thermal and electrical energy transfer between the two sides.
|
|
// Assume temperature equalizes, i.e. Ta*cA + Tb*cB = Tf*(cA+cB)
|
|
var Tf = (airA.Temperature * cA + airB.Temperature * cB) / (cA + cB);
|
|
// The maximum energy we can extract is (Ta - Tf)*cA, which is equal to (Tf - Tb)*cB
|
|
var Wmax = MathF.Abs(airA.Temperature - Tf) * cA;
|
|
|
|
var N = component.ThermalEfficiency;
|
|
|
|
// Calculate Carnot efficiency
|
|
var Thot = hotA ? airA.Temperature : airB.Temperature;
|
|
var Tcold = hotA ? airB.Temperature : airA.Temperature;
|
|
var Nmax = 1 - Tcold / Thot;
|
|
N = MathF.Min(N, Nmax); // clamp by Carnot efficiency
|
|
|
|
// Reduce efficiency at low temperature differences to encourage burn chambers (instead
|
|
// of just feeding the TEG room temperature gas from an infinite gas miner).
|
|
var dT = Thot - Tcold;
|
|
N *= MathF.Tanh(dT/700); // https://www.wolframalpha.com/input?i=tanh(x/700)+from+0+to+1000
|
|
|
|
var transfer = Wmax * N;
|
|
electricalEnergy = transfer * 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 (_pointLight.TryGetLight(uid, out var pointLight))
|
|
{
|
|
_pointLight.SetEnabled(uid, powered, pointLight);
|
|
_pointLight.SetColor(uid, speed == TegCirculatorSpeed.SpeedFast ? circ.LightColorFast : circ.LightColorSlow, pointLight);
|
|
}
|
|
}
|
|
|
|
private void GeneratorPowerChange(EntityUid uid, TegGeneratorComponent component, ref PowerChangedEvent args)
|
|
{
|
|
// TODO: I wish power events didn't go out on shutdown.
|
|
if (TerminatingOrDeleted(uid))
|
|
return;
|
|
|
|
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);
|
|
}
|
|
}
|