[ANNOUNCE] node-red-contrib-tank-volume: version 1.1.0

Hi folks,

Got some feature requests for node-red-contrib-tank-volume, which are available now in the palette as version 1.1.0.

This release contains following changes:

  1. Ability to specify the input dimensions in millimeters.

  2. Support of the new tank type "horizontal stadium":


  3. Support of the new tank type "vertical stadium":


  4. Documentation (incl. example flow) about tank batteries.

Hopefully it can also be useful for others.


Could you not have one tank type for those two and work it out automatically based on the dimensions? Height > Width implies vertical.

Hi @colin,
Yes I could do that, but the calculations for a partial filled tank are completely different. And if you make the vertical geometry wider, you won't get the horizontal geometry anyway.
And even if both types would have more things in common, it wouldn't have fit in the current design of my node anyway.

I will have to get back to you on that one when I have pondered the problem. The mathematician in me tells me that should not be the case. When height == width they are both the same (circular cross section), and both types are special cases of a rectangular tank with radiused corners. I need to think about the maths a bit.

Suppose there is not much oil in the tank, like in my above images:

  • In the verical only part of the lower (half) cylinder is filled.
  • In the horizontal two (half) cylinders are partly filled, and the rectangular part is partly filled.

So for partial filling, the volume calculation is different.

Hope that is a bit more clear?

What am I doing wrong here please, testing it with a horizontal cylinder, diameter 2, length 1, and depth of fluid 2 and I get "The measured depth (2) is larger than the tank height (1)"

[{"id":"cf2d1b41322c2738","type":"inject","z":"bdd7be38.d3b55","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"2","payloadType":"num","x":150,"y":4680,"wires":[["f982713ab625e82c"]]},{"id":"f982713ab625e82c","type":"tank-volume","z":"bdd7be38.d3b55","name":"","tankType":"horiz_cylin","inputField":"payload","outputField":"payload","measurement":"fluid","inputUnit1":"cm","inputUnit2":"l","outputUnit":"cm3","topLimit":0,"bottomLimit":0,"diameter":"2","length":"1","width":"100","height":"500","length2":0,"width2":0,"height2":0,"coneHeight":0,"cylinderHeight":0,"diameterTop":0,"diameterBottom":0,"customTable":[],"x":330,"y":4680,"wires":[["34ec77c869eb7735"]]},{"id":"34ec77c869eb7735","type":"debug","z":"bdd7be38.d3b55","name":"debug 60","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":520,"y":4680,"wires":[]}]

Seems you hit a bug. Is fixed now on Github.

You tank is 2 cm high and 1 cm long. Seems like you have a very efficient heating system. Think you will survive the high oil prices :wink:

Will have a play with this node - looks interesting.

I have recorded, over time, the depth recorded by my oil watchman, and the amount of oil needed to fill my tank.

I used https://mycurvefit.com/ to generate a best fit formula, and I use that to estimate how much oil it will take to fill my tank - after a number of readings this has proven to be be very accurate.

I also daily check the current price of oil, and graph the fill amount and fill cost, so I can determine when it's best to fill my tank up.

All bets are off for this year though!

Perhaps it is a tank for a model aeroplane engine. Or perhaps I find it easier to test things with small numbers :slight_smile:

Here is a function which works with horizontal cylinder, vertical and horizontal stadium and rectangular prism with round corners, so combined vertical and horizontal stadium, with round corners where radius of corners is less than half width or height. The first three shapes are special cases of the last one.
I have used the partial cylinder function from your node.

 * Calculates the volume of a partially filled tank which is a 
 * horizontal cylender, a horizontal stadium or a vertical stadium
 * or a rectangular prism with rounded edges along the length
 * Given msg.payload containing and object containing the properties
 * length, width, height, optionally cornerRadius, and depth (of fluid)
 * If cornerRadius is null, 0 or not supplied then the tank is assumed to be cylindrical, or horizontal or vertical stadium
 * If width == height then the tank is cylindrical
 * If the radius is supplied then it is a rectangular prism with rounded corners.
 * On return msg.payload contains the volume in cubic units of whatever the dimensions are in
msg.payload = getPartialVolumeMultiShape( msg.payload.length, msg.payload.width, msg.payload.height,
  msg.payload.radius, msg.payload.depth)
return msg  

function getPartialVolumeMultiShape(length, width, height, radius, depth) {
    // calculate radius if not supplied or zero
    if (!radius) {
        radius = Math.min(width, height)/2
        //node.warn(`Radius: ${radius}`)
    // calculate depths in rounded sections at the top and bottom, and in full width rectangular section
    let roundDepth
    let rectDepth
    if (depth < radius) {
        roundDepth = depth
        rectDepth= 0
    } else if (depth <= height - radius) {
        roundDepth = radius
        rectDepth = depth - radius
    } else {
        // fluid up into the top rounded section
        roundDepth = depth - (height - 2*radius)
        rectDepth = height - 2*radius
    //node.warn(`roundDepth: ${roundDepth},  rectDepth: ${rectDepth}`)
    const roundVolume = getPartialVolumeHorizonalCylinder(radius, length, roundDepth) + (width - 2*radius) * roundDepth * length
    const rectVolume = width * rectDepth * length
    //node.warn(`roundVolume: ${roundVolume},  rectVolume: ${rectVolume}`)
    return roundVolume + rectVolume

function getPartialVolumeHorizonalCylinder(radius, length, fluidHeight) {
    if (radius == undefined) throw "The diameter of the horizontal cylinder is undefined";
    if (length == undefined) throw "The length of the horizontal cylinder is undefined";
    if (fluidHeight == undefined) throw "The fill level of the horizontal cylinder is undefined";

    var angle = 2 * Math.acos((radius - fluidHeight) / radius); // See θ
    return 0.5 * Math.pow(radius, 2) * (angle - Math.sin(angle)) * length;

Test flow:

[{"id":"669c9dd973dff1e9","type":"function","z":"bdd7be38.d3b55","name":"Volume","func":"/**\n * Calculates the volume of a partially filled tank which is a \n * horizontal cylender, a horizontal stadium or a vertical stadium\n * or a rectangular prism with rounded edges along the length\n * \n * Given msg.payload containing and object containing the properties\n * length, width, height, optionally cornerRadius, and depth (of fluid)\n * If cornerRadius is null, 0 or not supplied then the tank is assumed to be cylindrical, or horizontal or vertical stadium\n * If width == height then the tank is cylindrical\n * If the radius is supplied then it is a rectangular prism with rounded corners.\n * \n * On return msg.payload contains the volume in cubic units of whatever the dimensions are in\n*/\nmsg.payload = getPartialVolumeMultiShape( msg.payload.length, msg.payload.width, msg.payload.height,\n  msg.payload.radius, msg.payload.depth)\nreturn msg  \n\n\nfunction getPartialVolumeMultiShape(length, width, height, radius, depth) {\n    // calculate radius if not supplied or zero\n    if (!radius) {\n        radius = Math.min(width, height)/2\n        //node.warn(`Radius: ${radius}`)\n    }\n    // calculate depths in rounded sections at the top and bottom, and in full width rectangular section\n    let roundDepth\n    let rectDepth\n    if (depth < radius) {\n        roundDepth = depth\n        rectDepth= 0\n    } else if (depth <= height - radius) {\n        roundDepth = radius\n        rectDepth = depth - radius\n    } else {\n        // fluid up into the top rounded section\n        roundDepth = depth - (height - 2*radius)\n        rectDepth = height - 2*radius\n    }\n    //node.warn(`roundDepth: ${roundDepth},  rectDepth: ${rectDepth}`)\n    const roundVolume = getPartialVolumeHorizonalCylinder(radius, length, roundDepth) + (width - 2*radius) * roundDepth * length\n    const rectVolume = width * rectDepth * length\n    //node.warn(`roundVolume: ${roundVolume},  rectVolume: ${rectVolume}`)\n    return roundVolume + rectVolume\n}\n\nfunction getPartialVolumeHorizonalCylinder(radius, length, fluidHeight) {\n    if (radius == undefined) throw \"The diameter of the horizontal cylinder is undefined\";\n    if (length == undefined) throw \"The length of the horizontal cylinder is undefined\";\n    if (fluidHeight == undefined) throw \"The fill level of the horizontal cylinder is undefined\";\n\n    var angle = 2 * Math.acos((radius - fluidHeight) / radius); // See θ\n    return 0.5 * Math.pow(radius, 2) * (angle - Math.sin(angle)) * length;\n}","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":540,"y":4260,"wires":[["bc3f542a653bd575"]]},{"id":"cfb009d142dffc93","type":"inject","z":"bdd7be38.d3b55","name":"{\"height\": 200, \"width\": 200, \"length\": 500, \"depth\": 200}","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"height\": 200, \"width\": 200, \"length\": 500, \"depth\": 200}","payloadType":"json","x":240,"y":4320,"wires":[["669c9dd973dff1e9","5e1d7493c1352c52"]]},{"id":"bc3f542a653bd575","type":"debug","z":"bdd7be38.d3b55","name":"FUNCTION","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":690,"y":4260,"wires":[]},{"id":"ec98ef0aa15ccb83","type":"inject","z":"bdd7be38.d3b55","name":"{\"height\":300,\"width\":200,\"length\":500,\"depth\":300}","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"height\":300,\"width\":200,\"length\":500,\"depth\":300}","payloadType":"json","x":230,"y":4560,"wires":[["669c9dd973dff1e9","8358a8c900581272"]]},{"id":"1e56c0901a028ab3","type":"inject","z":"bdd7be38.d3b55","name":"{\"height\": 200, \"width\": 200, \"length\": 500, \"depth\": 100}","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"height\": 200, \"width\": 200, \"length\": 500, \"depth\": 100}","payloadType":"json","x":240,"y":4360,"wires":[["669c9dd973dff1e9","5e1d7493c1352c52"]]},{"id":"86b3e3a7d5733958","type":"inject","z":"bdd7be38.d3b55","name":"{\"height\":200,\"width\":300,\"length\":500,\"depth\":200}","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"height\":200,\"width\":300,\"length\": 500,\"depth\":200}","payloadType":"json","x":230,"y":4440,"wires":[["669c9dd973dff1e9","3d081ee8c1bddd34"]]},{"id":"6dc4bd2072ada370","type":"tank-volume","z":"bdd7be38.d3b55","name":"","tankType":"horiz_cylin","inputField":"payload","outputField":"payload","measurement":"fluid","inputUnit1":"cm","inputUnit2":"l","outputUnit":"cm3","topLimit":0,"bottomLimit":0,"diameter":"200","length":"500","width":"100","height":"500","length2":0,"width2":0,"height2":0,"coneHeight":0,"cylinderHeight":0,"diameterTop":0,"diameterBottom":0,"customTable":[],"x":610,"y":4380,"wires":[["8af0df3473e2c1f2"]]},{"id":"5e1d7493c1352c52","type":"change","z":"bdd7be38.d3b55","name":"","rules":[{"t":"move","p":"payload.depth","pt":"msg","to":"payload.measuredHeight","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":590,"y":4340,"wires":[["6dc4bd2072ada370"]]},{"id":"8af0df3473e2c1f2","type":"debug","z":"bdd7be38.d3b55","name":"CONTRIB","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload.filledVolume","targetType":"msg","statusVal":"","statusType":"auto","x":820,"y":4380,"wires":[]},{"id":"3d081ee8c1bddd34","type":"change","z":"bdd7be38.d3b55","name":"","rules":[{"t":"move","p":"payload.depth","pt":"msg","to":"payload.measuredHeight","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":550,"y":4460,"wires":[["6998747a59900869"]]},{"id":"6998747a59900869","type":"tank-volume","z":"bdd7be38.d3b55","name":"","tankType":"horiz_stad","inputField":"payload","outputField":"payload","measurement":"fluid","inputUnit1":"cm","inputUnit2":"l","outputUnit":"cm3","topLimit":0,"bottomLimit":0,"diameter":"200","length":"500","width":"300","height":"200","length2":0,"width2":0,"height2":0,"coneHeight":0,"cylinderHeight":0,"diameterTop":0,"diameterBottom":0,"customTable":[],"x":610,"y":4500,"wires":[["8af0df3473e2c1f2"]]},{"id":"b288ca2ca5a63ed7","type":"inject","z":"bdd7be38.d3b55","name":"{\"height\":200,\"width\":300\"length\":500,\"depth\":100}","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"height\":200,\"width\":300,\"length\":500,\"depth\":100}","payloadType":"json","x":220,"y":4480,"wires":[["669c9dd973dff1e9","3d081ee8c1bddd34"]]},{"id":"b47125dbe93c2686","type":"inject","z":"bdd7be38.d3b55","name":"{\"height\":300,\"width\":200,\"length\":500,\"depth\":75}","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"height\":300,\"width\":200,\"length\":500,\"depth\":75}","payloadType":"json","x":220,"y":4640,"wires":[["669c9dd973dff1e9","8358a8c900581272"]]},{"id":"5c5514738de5bb52","type":"tank-volume","z":"bdd7be38.d3b55","name":"","tankType":"vert_stad","inputField":"payload","outputField":"payload","measurement":"fluid","inputUnit1":"cm","inputUnit2":"l","outputUnit":"cm3","topLimit":0,"bottomLimit":0,"diameter":"200","length":"500","width":"200","height":"300","length2":0,"width2":0,"height2":0,"coneHeight":0,"cylinderHeight":0,"diameterTop":0,"diameterBottom":0,"customTable":[],"x":620,"y":4620,"wires":[["8af0df3473e2c1f2"]]},{"id":"8358a8c900581272","type":"change","z":"bdd7be38.d3b55","name":"","rules":[{"t":"move","p":"payload.depth","pt":"msg","to":"payload.measuredHeight","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":570,"y":4580,"wires":[["5c5514738de5bb52"]]},{"id":"f85444c64dd07b07","type":"inject","z":"bdd7be38.d3b55","name":"{\"height\":300,\"width\":200,\"length\":500,\"depth\":250}","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"height\":300,\"width\":200,\"length\":500,\"depth\":250}","payloadType":"json","x":230,"y":4600,"wires":[["8358a8c900581272","669c9dd973dff1e9"]]}]

Hey Colin,
That is indeed a nice one. Never thought about it that way.
Thanks for the insight!!

Thanks for the link @RandomSporadicProjs!
So you use this to have better estimates compared to linear interpolation...
Could you explain please a bit more in detail how you use this? E.g. do you more measurements when you are near the top or bottom of your tank (because your liquid level will change faster over there)? And do you enter the values manually in that site? And so on...

Sure - I'll explain what I did

I'm not exactly sure of the real shape of my tank as it's bunded, but think it's a horizontal stadium with probably a lot of indentations in it for mechanical strength.

I have an oil watchman (https://www.fueltankshop.co.uk/watchman-sonic/p1391), normally if you use the plug in the wall receiver you get an indication in 10% chunks how full your tank is.

However, the protocol that an oil watchman transmits had been decoded, and that incorporated into rtl_433 (GitHub - merbanan/rtl_433: Program to decode radio transmissions from devices on the ISM bands (and other frequencies))

So I have a raspberry pi with an USB SDR dongle in it, and that receives the data from the watchman, instead of the original receiver - this happens approx every 45 minutes. This decodes the signal and I can get depth measurements in cm. and I feed that into node red.

The object looks like

{"time":"2018-04-23 16:56:50","model":"Oil Watchman","id":uniqueIdgoes here,"flags":128,"maybetemp":23,"temperature_C":10,"binding_countdown":0,"depth":79}

And I used the depth value.

So over the years I have recorded how much oil it takes to fill my tank from a given depth in cm as measured by the watchman. It's bound to vary a little as the cut off depends on how much oil the refiller decides will fit in the tank.

//depth reading of 8cm is when the tank is full
//depth reading of 47 corresponds to 766 litres so approx. 16.3 litres per cm
//depth reading of 48 corresponds to 758 litres so approx. 16 litres per cm
//depth reading of 50 corresponds to 806 litres so approx. 16.2 litres per cm
//depth reading of 55 corresponds to 971 litres so approx. 17.7 litres per cm
//depth reading of 61 corresponds to 1100 litres so approx. 18.0 litres per cm
//depth reading of 83 corresponds to 1741 litres so approx. 21 litres per cm
//depth reading of 84 corresponds to 1688 litres so approx. 20 litres per cm

So I put the data in a spreadsheet

Then copy and paste it into

into mycurvefit.com

Clear the data is shows first, then answer a couple of questions to confirm which is x and y and you get a graph.

We can see what functions produce the best fit (obviously the more readings the better, but a mix of some that are small top ups, and some that are approx half full and some when empty is probably best.

So, unscientifically I picked the best function that fit my points so I presently use the equation
y = 6307.956 + (-41.83459 - 6307.956)/(1 + (x/145.8344)^1.726393)

and the fit is good to my eyes.

I update the fit each time I get more refill data.

I can then take the reading from the SDR and get a good estimate of the amount of oil remaining, and the refill amount, and I display a gauge and graph in node red of this, and as I say, I also pull daily the oil price and graph fill amount and fill cost, so I can determine the best time to refill.

I would say that my last few guesses to fill amounts are within 50-80 litres, which given that my tank is 2000l ish is accurate enough, given 'full' is subjective.

Also I tend to fill up the tank when in the same depth regions, so it's not usual to get eg small top ups as I think my minimum delivery is 500 litres.

Hope this helps

1 Like

Very interesting. Thanks for the nice explanation.
It would be nice if I could integrate this kind of functionality in my node. So that I do a curve fitting automatically based on the points that a user has entered in the existing screen:


And that a formula is calculated automatically, which is used when a new depth value arrives. Currently I use linear interpolation, which is only good in some specific tank values.

Would need a function that can calculate a formula (to fit the curve) based on some height-volume pairs, and can calculate a new y value (= volume) when an x value (= height) is entered. Did a quick search on npm, but don't think that libraries like e.g. fit-curve can calculate a new y value when I enter a new x value?

If anybody knows a library that can do this, please share it and I will implement it...

maybe this function will help

[{"id":"436cd17b.16bc68","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0","payloadType":"num","x":70,"y":1420,"wires":[["e60ff663.18d6d8"]]},{"id":"e60ff663.18d6d8","type":"function","z":"30af2d3e.d94ea2","name":"","func":"//const string_payload = msg.payload.toString();\nconst lookup = {\n    \"120\": 2000,\n    \"80\": 1500,\n    \"20\": 500,\n    \"10\": 0\n};\nlet output = lookup[msg.payload.toString()];\nif(output === undefined){\n    const lookup_entries = Object.entries(lookup);\n    let min_volume;\n    let max_volume;\n    let max_height;\n    let min_height;\n    let min_key;\n    for(const [key, value] of lookup_entries){\n        if (Number(key) >= msg.payload){\n            max_volume = value;\n            max_height = Number(key);\n            break;\n        }else{\n            min_volume = value;\n            min_height = Number(key);\n            min_key = key;\n        }\n    }\n    const over_height =  msg.payload - min_height;\n    output =  lookup[min_key] + (max_volume - min_volume) / (max_height - min_height) * over_height;\n}\nif(isNaN(output)){\n    msg = null;\n    node.warn(\"Out of range\");\n}else{\n    msg.payload = Math.round(output);\n}\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":270,"y":1520,"wires":[["b564db6d.4a295"]]},{"id":"a815dad5.e35d28","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"10","payloadType":"num","x":80,"y":1500,"wires":[["e60ff663.18d6d8"]]},{"id":"d9af3c17.c471b","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"20","payloadType":"num","x":90,"y":1560,"wires":[["e60ff663.18d6d8"]]},{"id":"7667048c.8b7aec","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"5","payloadType":"num","x":90,"y":1460,"wires":[["e60ff663.18d6d8"]]},{"id":"4bc2316e.b98ac","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"25","payloadType":"num","x":70,"y":1600,"wires":[["e60ff663.18d6d8"]]},{"id":"252f7777.5cc6f8","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"79","payloadType":"num","x":70,"y":1640,"wires":[["e60ff663.18d6d8"]]},{"id":"701f6e21.488878","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"119","payloadType":"num","x":70,"y":1680,"wires":[["e60ff663.18d6d8"]]},{"id":"76bc5a85.da9e44","type":"inject","z":"30af2d3e.d94ea2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"121","payloadType":"num","x":70,"y":1720,"wires":[["e60ff663.18d6d8"]]},{"id":"b564db6d.4a295","type":"debug","z":"30af2d3e.d94ea2","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":460,"y":1520,"wires":[]}]

It should map any lookup object, height to volume. Not fully tested.

1 Like

Hi @E1cid,
Sorry for the delay!
Thanks for taking time to create a working flow!!
But if I am not mistaken this is linear interpolation between the heigh-volume pairs?

I was wondering if I could implement something like @RandomSporadicProjs is doing (via the external website):

  1. The user enters a map of height-volume pairs:

    const lookup = {
       "120": 2000,
       "80": 1500,
       "20": 500,
       "10": 0
  2. I would like to calculate a curve that fits all those points (like the website does), so this gives me a formula of the function.

  3. When you now inject the measured height, this height is entered into the formula. So I have now an estimated volume based on the curve.

I had a quick look last weekend at some npm packages that would allow me to implement easily step 2 and 3, but I didn't find it. E.g. packages like linear-least-squares, fit-curve, points-on-curve, regression-js, ...

So if anybody knows an npm package to achieve this, please give me a call!

Seems a bit overkill just to get a tank level, unless you are planning a moon trip.

The tanks i have worked on came with a fill level table, those points can be entered and a decent volume can be obtained. You still get a curve just made of smaller straight lines. The function i suggested can take any number of points. One advantage of this is you can tailor areas of the tank.

Good luck hunting.