Skip to content

Instantly share code, notes, and snippets.

@w4rum
Last active January 31, 2024 14:46
Show Gist options
  • Save w4rum/9add42727114fd335537c1dcb7b55c6e to your computer and use it in GitHub Desktop.
Save w4rum/9add42727114fd335537c1dcb7b55c6e to your computer and use it in GitHub Desktop.
In-depth Reactor Guide and Fission Controller (last updated for 0.13.3.11)

In-depth Reactor Guide and Fission Controller (last updated for 0.13.3.11)

This guide first explains some advanced features of the Barotrauma reactor system and then combines a PD controller with some reverse engineering of Barotrauma's source code to build a working fission controller. This guide does not build a turbine controller. Please see Rob Smith's video for a really great turbine controller that works well with this fission controller.

Quick Start

If you don't care about the details, just copy the following setup to your submarine. The wiring should be pretty self-explanatory from the layout, with the wires always going from the left to the right.

Component list (thanks to @FinetalPies):

  • 4 WiFi
  • 6 Memory
  • 2 Delay
  • 3 Adder
  • 2 Subtract
  • 3 Multiply
  • 3 Divide
  • 25 Wires

Advanced Reactor Behaviour

Before we can create a working controller, we need to find out how exactly the reactor works.

Fission Rate vs. Turbine Output. What's more important?

Short answer: Turbine Output. If you want a complete reactor controller, then get a turbine controller first and worry about the fission controller later.

Long answer: Setting the turbine output correctly requires a high amount of precision. Your turbine output directly determines your power output.

  • If your power output is even slightly* too low, your devices stop working.
  • If your power output is even slightly* too high, your devices immediately start taking damage.

Temperature just needs to be "somewhat okay" for your reactor to work perfectly.

  • If your temperature is below 30%**, then your power output is reduced.
  • If your temperature is above 70%**, then your overheating timer starts, which will cause a fire and then a meltdown if the temperature is not reduced below 70% again.
  • If your temperature is anywhere between 30% and 70%, then your reactor runs perfectly. There is no difference between, e.g., 40% and 60%.

As long as the temperature is somewhat correct, the turbine output is the only thing that matters. So your turbine controller shouldn't worry about temperature and should just try to match the power output to the load. And now we'll make sure our temperature actually stays in that sweet spot.

(*The game has a little bit of wiggle room when it comes to setting the optimal turbine output. When your power output gets close enough to the load, you will see the power output suddenly jump to match your load exactly. That's there to make it possible for us imperfect humans to control the reactor. This wiggle room also scales with your character skills. See this comment in the source code. But it's nowhere near as much wiggle room as temperature has.)

(**These values aren't exactly accurate because the optimal temperature range scales with your character skill. However, our controller will be accurate enough so that we don't have to worry about these details.)

How is temperature calculated?

At the core of our fission controller stands the question:

What is the correct fission rate to maintain optimal temperature?

In order to answer that, we need to look at how temperature is calculated, which is surprisingly simple: If we take a look at the relevant section of the source code, then we can see that temperature behaves as follows:

  • Calculate the resting temperature based on the current fission rate and current turbine output.
  • If the current temperature is different than the resting temperature, move the current temperature towards the resting temperature at a speed of up to 10 units per tick.

The two most important takeaways here are:

  1. Temperature change depends on fission rate and turbine output.
    • => We need to know the output of our turbine controller.
  2. Temperature only ever changes by 10 units per tick.
    • => No matter how far the current temperature is from the resting temperature, the temperature will only move at a slow and constant speed. That means that our fission controller can be slow and it's also acceptable if our fission rate fluctuates a bit.

Okay, I kind of glanced over a really important detail here:

How is the resting temperature calculated?

To answer this question, we need to dig a bit further into the source code. Don't worry, I'll summarize it for you. If you want the juicy details, check this section of the source code and the GetGeneratedHeat function that's referenced there.

The interesting bit works as follows:

generated_heat = fission_rate * (fuel_heat_value / 100.0) * 2.0
resting_temperature = generated_heat - turbine_output

So as you can see, heat is generated based on our fission rate and our fuel. To be precise, 1 unit of fission generates 2 units of heat, multiplied by our fuel's heat value. And 1 unit of turbine output costs us 1 unit of heat. Our resting temperature is then determined by the excess heat. If we want to be at 50% heat, then we need to produce 50 units of heat more than we lose to our turbine.

Example: If we're running a single Thorium Fuel Rod (heat value = 100%) and our turbine spins at 100%, then we need to set our fission rate to 75. This will generate 150 heat, then consume 100 heat via the turbine and leave 50 excess heat to reach a temperature of 50%.

Let's rearrange the equations above to get a formula for our desired fission rate:

target_fission_rate = (target_temperature + turbine_output) / fuel * 100 / 2

Well, that's great! We're done now, aren't we? We made the perfect controller that can't possibly be improved any further.

Well, there is a tiny little detail that I left out. And that detail is a bit of a problem.

Sliders vs. Needles

If you've played Barotrauma for a while then you might think that in the following image, the left slider determines the fission rate and the right slider determines the turbine output.

But that's not correct.

The fission rate and turbine output are actually determined by the needles, not the sliders. The sliders merely set the target position of the needles, and then the needles will slowly approach that target position. See these lines in the source code for details.

There are two problems with controlling the needles:

  1. The needles move slowly, they lag behind. And while they lag behind, fission rate and turbine output will be different to what we want them to be. So not only does our temperature lag behind, our fission rate also lags behind.
  2. The closer the needles get to their target point, the slower they become. The needles are a lot faster when they're far away from their target point.

Now we can't do anything against problem 1. That's just how the game works. The needles have a maximum speed and we can't overcome that.

But we can combat problem 2. If we want to get from 0 to 50 as quick as possible, just do the following:

  • Set the slider to 100.
  • The needle will start moving to the right with maximum speed.
  • Just when the needle hits 50, set the slider to 50.
  • The needle will instantly stop at 50.

If you want to reach another value than 50, then the strategy stays the same: Set the slider to the most extreme value and move back right when the needle is where it needs to be.

If we can control the slider and if we know the current position of the needle, then we can now move the needle to its target position with the maximum speed possible.

Okay, that's good, right? A bit troublesome to implement but nothing we can't solve with a handful of components. Are we done now?

Well, there is another tiny detail that I left out. And that tiny detail is a very big problem.

Reactor Pin Layout

If we want to construct a controller based on in-game components, then we need to work with the reactor's pins. If you're reading this, then you have no doubt seen this before. Nevertheless, we need to talk about it.

Let's highlight the important pins here:

  • FUEL_OUT: The combined heat value of all of your fuel rods. Not the remaining fuel durability. If you had 4 Fulgurium Fuel Rods (150 heat each) in there, then FUEL_OUT would be 600.
  • TEMPERATURE_OUT: The current reactor temperature * 100. Yes, you read that correctly. The source code works with a temperature between 0 and 100. But for some reason (immersion I guess), this pin has a multiplier of 100, so it's outputting a temperature between 0 and 10,000. In our controller, we'll just divide by 100 to get back to the values we're used to.
  • SET_TURBINEOUTPUT: The slider of the turbine output. This is what our turbine controller will set.
  • LOAD_VALUE_OUT: Current power load in kW. On the Typhon 2, this is typically between 0 and 6000.
  • POWER_VALUE_OUT: Current power output in kW. This is turbine output (= the turbine output needle) * your reactor's maximum power output. On the Typhon 2 without any upgrades, the maximum reactor output is 5000 kW. Our turbine controller uses this to determine the position of the turbine output needle:
    turbine_output_needle_position = POWER_VALUE_OUT / reactor_max_power
  • SET_FISSIONRATE: The slider of the fission rate. This is what our fission controller will set.

Do you see what's missing here? Do you see what crucial piece of information the turbine controller gets but the fission controller doesn't?

The position of the needle.

There is no way to determine the current position of the fission rate needle. You only get the slider position (which you set yourself) and the current temperature, which might be very different from the resting temperature.

(You could, in theory, move the slider to 0 until you're sure that the needle is at 0, then check the source code on how exactly the needle behaves, and then simulate the needle position for yourself as you go. But that would be rather complex and very prone to synchronization errors.)

Damn! That's all of our research and neat little formulas down the drain then, right?

Well, not quite. But this is where things get a bit... inaccurate.

PD Controller + Offset

(Take what I'm saying in this section with a grain of salt. I tried taking electrical engineering as a minor to learn about controllers etc. and I utterly failed. Electrical engineering is hard. I only know the basics of how controllers work, so there might be quite a few inaccuracies and possible improvements in this section. Feel free to point them out if you spot them.)

If you're looking to control some output value of a complex system but you can only directly influence a somewhat-disconnected input value, then a simple PID Controller is sometimes a good-enough sledgehammer solution to your problems. This is also what Rob Smith uses in his video that is linked at the top of this guide. Unfortunately, I couldn't get his PID controller to work and his PID controller doesn't leverage the information we have gained by looking through the source code.

If you need a quick crash course on what a PID controller does, then the following is all you need to know for now:

  • A PID controller looks at the current output value (in our case, the current temperature) and the target output value (in our case, 50 temperature) and calculates the error by just subtracting them.
    error = target_output - current_output
  • Then it calculates three values that are added together to determine the new input value (in our case, the fission rate slider position):
    new_input = proportional_part + integral_part + derivative_part
    Don't worry. While these words are very fancy, the concept behind them is quite simple:
  • Proportional part: The bigger the error, the harder we need to adjust. This is calculated by just multiplying the current error with some configurable factor called kP. This will reduce the error until we (hopefully) hit zero.
    # kP is a configurable factor
    proportional_part = error * kP
  • Integral part: If we keep seeing an error for a long time, then might have to overshoot / undershoot a little to get where we want. This is calculated by summing up the past error values. This helps in cases where there is some kind of offset between our input and our output. The proportional part is just a multiplication and can't deal with offsets.
    # kI is a configurable factor
    integral_part = sum_over_last_x_errors * kI
    In our case, we have an offset. If we're at 0 error, then that means that we're at the target temperature. But the proportional part will now say
    proportional_part = 0 * kP = 0
    Obviously, we don't want to go back to 0 fission rate. We want to stay where we are! So that means that we have an offset.
  • Derivative part: If we're reducing the error very quickly in a short amount of time, then slow down to avoid overshooting our target. This is calculated (in a simple setup) by looking at the previous error and the current error and subtracting them. The higher the difference, the faster we are reducing the error. So brake harder if the difference is high.
    # very simple variant
    # kD is a configurable factor
    derivative_part = (error - previous_error) * kD

Now after you've read through all that and are trying to get that into your head, let me make it a little easier right away:

We don't need the integral part.

As I said above, the integral part is useful when there is some kind of unknown offset between the input and the output. But that's not the case here. We know exactly at which fission rate we should be when we're at 0 error.

If we're at the target temperature, then we should have the fission rate that we calculated with our neat little formulas above.

Final Product

Let's put it all together then!

Check the following images on how to build a PD controller with a custom offset.

Overview

The following could be compressed quite a bit to save space on smaller ships but the Typhon 2 has a lot of room in the Central Access Passage so I went for maximum readability.

Values and Cables

I'll explain the values that you see below:

  • Value 100 on the memory component next to TEMPERATURE_OUT: This is just taking out the multiplier of 100 that the TEMPERATURE_OUT pin has.
  • Value 55 on the memory component between TEMPERATURE_OUT and SET_TURBINEOUTPUT: This is our target temperature. Why 55 and not 50? Because 55 looks nicer in the interface. This controller manages to keep the temperature at +- 2% from the target temperature in my experiments, so we have a bit of room to spare.
  • Value 0.05 and Delay 0.05 on the delay components and the first memory component in the derivative part: This is the length of the interval that we keep one iteration of our controller. Working with discrete time instead of continuous time makes thinking about this controller a little easier and we're less dependent on the order in which the game decides to process the components. I just kept Rob Smith's 20Hz update frequency (= 0.05s period) without putting too much thought into it.
  • Value 50 in the offset part. This is just the 100 / 2 that you see as part of the formula that we found earlier.
  • Value 3.425 in the proportional part and Value 0.458 in the derivative part: These are the PID tuning parameters kP and kD. I found these values following the Ziegler-Nichols method for PID controller tuning.

For the cables, I tried to make it fairly obvious as to which cable goes into which slot. All of the cables go left-to-right and the output is always on the right side. If a cable comes from the top, it's SIGNAL_IN_1. If it comes from the bottom, it's SIGNAL_IN_2. If a cable comes from the left, then it either doesn't matter (only one input or it's an addition or multiplication) or you just use whichever input is left after following the other rules above.

There is one exception to this: The subtraction component on the left is flipped. The divide component from above goes into SIGNAL_IN_2 and the addition component from below goes into SIGNAL_IN_1.

Feedback

I hope found this little guide useful. I've been using this controller for a couple of sessions now and it has effectively put our electrician out of work.

If you have any criticisms or suggestions, please comment them on this Gist, on Reddit or send me a Discord DM @ Tim | w4rum#4344

@Spiraljunky
Copy link

Hey Tim, this is amazing, both Rob Smith's video and this.
Just a quick thing, would you be able to add a list of all the needed components and wires? Rob's video did so and it just made it much easier for quick glance for buying everything in bulk when you stop by a colony etc.
Anyway, thanks for this great post!

@FinetalPies
Copy link

Thanks! Got it all working, great stuff and it's been fun to learn just a little bit of like, actual math and engineering

@FinetalPies
Copy link

Through extreme experimentation I found a design flaw. If the power demand is higher than the reactor can possibly output, the fission controller seems to believe that it's not matching power demand for lack of fission and will overheat. Since the max reactor output is already stored in a memory cell in Rob Smith's turbine controller, I seems like it'd be very possible to like, change a behaviour if it reads the load as higher than the output. Not sure what that behaviour change would be exactly...

Also to be helpful and answer Spiraljunky's suggestion, here's the parts list:
4 WiFi
6 Memory
2 Delay
3 Adder
2 Subtract
3 Multiply
3 Divide
25 Wires

@checkraisefold
Copy link

What's the purpose of doing 0.05 / (error - previous_error) in the derivative portion of the circuit?

@w4rum
Copy link
Author

w4rum commented Jun 22, 2021

@FinetalPies
Thanks! I've added the list to the gist.

@checkraisefold
It's been a while since I worked on this and I don't play Barotrauma at the moment but I think the intention was to normalize the difference between the previous and current error in regards to the update frequency (20 Hz frequency = 0.05s period). Looking back at this, the division seems backward. Intuitively, I'd expect it to be (error - previous_error) / 0.05. I'd have to load up the game and check whether that intuition is correct and, if it is, check why it still works this way.

I also realized that I haven't taken this normalization into account when using the Ziegler-Nichols method. Given that I've measured the "ultimate gain" and oscillation period for the Ziegler-Nichols method with a 20 Hz update frequency, it doesn't really seem to make sense to divide the update frequency out of the derivative part.

So this might just be plain wrong and cause by a lack of understanding of PID controllers on my part. I think it's possible that the division component in that place is completely unnecessary (and maybe even makes the derivative part too strong). I won't be getting to checking this any time soon though.

@checkraisefold
Copy link

@checkraisefold
It's been a while since I worked on this and I don't play Barotrauma at the moment but I think the intention was to normalize the difference between the previous and current error in regards to the update frequency (20 Hz frequency = 0.05s period). Looking back at this, the division seems backward. Intuitively, I'd expect it to be (error - previous_error) / 0.05. I'd have to load up the game and check whether that intuition is correct and, if it is, check why it still works this way.
...

Yeah, that's what I noticed. I doubted you were wrong at first, but I was like "why tf does this work? it should be the other way around?"
I'll definitely test this in a bit and report back

@checkraisefold
Copy link

@w4rum Discord in the gist seems to be outdated. New one?

@w4rum
Copy link
Author

w4rum commented Jun 22, 2021

@w4rum Discord in the gist seems to be outdated. New one?

Nope, still Tim | w4rum#4344.

@checkraisefold
Copy link

@w4rum Discord in the gist seems to be outdated. New one?

Nope, still Tim | w4rum#4344.

Sent a request

@w4rum
Copy link
Author

w4rum commented Jun 22, 2021

Through extreme experimentation I found a design flaw. If the power demand is higher than the reactor can possibly output, the fission controller seems to believe that it's not matching power demand for lack of fission and will overheat. Since the max reactor output is already stored in a memory cell in Rob Smith's turbine controller, I seems like it'd be very possible to like, change a behaviour if it reads the load as higher than the output. Not sure what that behaviour change would be exactly...

Are you talking about a scenario in which the turbine controller outputs a value higher than 100? I think I came across that problem during my experiments and fixed it by just clamping the turbine controller output to [0, 100].

@cl0ck-byte
Copy link

Don't bother with this unless you want to blow your reactor
From what I'm understanding this is basically a version of pre-patch bang-bang reactor which doesn't work anymore as slider speed is capped.

@checkraisefold
Copy link

Don't bother with this unless you want to blow your reactor From what I'm understanding this is basically a version of pre-patch bang-bang reactor which doesn't work anymore as slider speed is capped.

This is a pre bang-bang design that uses a PD (PID with no integral) design instead. It doesn't work because of the garbage update Baro devs did where automatic reactor controllers are useless since theres like a 1 or 2 second delay on input control signals.

@cl0ck-byte
Copy link

automatic reactor controllers are useless

ARCs aren't exactly useless - they can boost automatic controller too if they agree on values

and on startups with all 4 rods it doesn't go nuclear unlike automatic controller

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment