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.DeviceNetwork.Events; using Content.Shared.Examine; using Content.Shared.Power; using Content.Shared.Power.EntitySystems; using Content.Shared.Power.Generation.Teg; using Content.Shared.Rounding; using Robust.Server.GameObjects; using Robust.Shared.Utility; namespace Content.Server.Power.Generation.Teg; /// /// Handles processing logic for the thermo-electric generator (TEG). /// /// /// /// 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. /// /// /// Connecting the TEG core to its circulators is implemented via a node group. See . /// /// /// The TEG center does HV power output, and must also be connected to an LV wire for the TEG to function. /// /// /// 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. /// /// /// /// /// /// public sealed class TegSystem : EntitySystem { /// /// Node name for the TEG part connection nodes (). /// private const string NodeNameTeg = "teg"; /// /// Node name for the inlet pipe of a circulator. /// private const string NodeNameInlet = "inlet"; /// /// Node name for the outlet pipe of a circulator. /// private const string NodeNameOutlet = "outlet"; /// /// Device network command to have the TEG output a object for its last statistics. /// public const string DeviceNetworkCommandSyncData = "teg_sync_data"; [Dependency] private readonly AmbientSoundSystem _ambientSound = default!; [Dependency] private readonly AppearanceSystem _appearance = default!; [Dependency] private readonly AtmosphereSystem _atmosphere = default!; [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!; [Dependency] private readonly PointLightSystem _pointLight = default!; [Dependency] private readonly SharedPowerReceiverSystem _receiver = default!; private EntityQuery _nodeContainerQuery; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(GeneratorUpdate); SubscribeLocalEvent(GeneratorPowerChange); SubscribeLocalEvent(DeviceNetworkPacketReceived); SubscribeLocalEvent(GeneratorExamined); _nodeContainerQuery = GetEntityQuery(); } 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(uid); args.PushMarkup(Loc.GetString("teg-generator-examine-power", ("power", supplier.CurrentSupply))); } } private void GeneratorUpdate(EntityUid uid, TegGeneratorComponent component, ref AtmosDeviceUpdateEvent args) { var supplier = Comp(uid); var powerReceiver = Comp(uid); if (!powerReceiver.Powered) { supplier.MaxSupply = 0; return; } var tegGroup = GetNodeGroup(uid); if (tegGroup is not { IsFullyBuilt: true }) 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 / args.dt; // Add ramp factor. This magics slight power into existence, but allows us to ramp up. supplier.MaxSupply = power * component.RampFactor; var circAComp = Comp(circA); var circBComp = Comp(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(uid); _receiver.SetPowerDisabled(uid, !group.IsFullyBuilt, powerReceiver); 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, component), false); } } private void UpdateCirculatorAppearance(Entity ent, bool powered) { if (!Resolve(ent, ref ent.Comp)) return; var circ = ent.Comp; 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(ent, TegVisuals.CirculatorSpeed, speed); _appearance.SetData(ent, TegVisuals.CirculatorPower, powered); if (_pointLight.TryGetLight(ent, out var pointLight)) { _pointLight.SetEnabled(ent, powered, pointLight); _pointLight.SetColor(ent, 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(uid), nodeGroup); } /// Null if the node group is not yet available. This can happen during initialization. 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(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); } }