<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Les pelotes de ficelle</title>
    <link>https://pf.olibrio.fr/en/</link>
    <atom:link href="https://pf.olibrio.fr/en/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Catch a loose end, pull the thread, unravel the whole ball. Sam’s blog: electronics, data, climate, AI and reverse engineering, home investigations.</description>
    <language>en</language>
    <lastBuildDate>Thu, 01 Oct 2026 12:00:00 +0000</lastBuildDate>
    <generator>build.py</generator>
    <item>
      <title>A broken spoke on my hub motor, and the tension meter I ended up welding</title>
      <link>https://pf.olibrio.fr/en/posts/tensiometre-rayons-moteur-roue.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/tensiometre-rayons-moteur-roue.html</guid>
      <pubDate>Thu, 01 Oct 2026 12:00:00 +0000</pubDate>
      <description>Second 2,000 W hub-motor wheel to lose a spoke. Instead of retensioning by ear, I designed and then welded a tension meter: three-point bending over almost the whole spoke, the force set by the click of a torque wrench, the reading taken with a caliper. The reasoning, the dead ends, the tool, the first numbers, and what remains to be measured.</description>
      <category>e-bike</category>
      <category>hub motor</category>
      <category>spokes</category>
      <category>spoke tension</category>
      <category>tension meter</category>
      <category>torque wrench</category>
      <category>diy</category>
      <category>welding</category>
      <category>mechanics</category>
      <category>ai</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/og-tensiometre.jpg" alt="A broken spoke on my hub motor, and the tension meter I ended up welding"></p>
<div class="tldr">
<p><strong>Two minutes, if you keep breaking spokes on a hub motor</strong></p>
<ul>
<li><strong>The symptom</strong>: a broken spoke on a 2,000 W hub motor, after a first wheel already lost the same way. Thirty-six short, thick spokes, 160 mm long and 2.6 mm thick, whose tension nobody knows.</li>
<li><strong>Why not a shop tension meter</strong>: on a spoke this short and this thick, a device that bends the spoke over 60 mm mostly measures the spoke&rsquo;s own stiffness, not its tension. And the paper table stops before the useful range anyway.</li>
<li><strong>The tool</strong>: three-point bending over 125 mm, almost the whole free length of the spoke. The force comes from the click of a torque wrench, so it is the same every time. Two skate bearings hold the spoke without friction, a caliper reads the deflection and keeps the maximum when the click releases the load.</li>
<li><strong>First numbers</strong>: at 5 Nm, 100 N of tension move the caliper by 0.07 mm. The wrench&rsquo;s scatter is worth 8 % of tension. The first spoke measured comes out around 850 N. And a mechanics lesson on the way: a mounted spoke is four times stiffer in bending than a free one, because its ends are held.</li>
<li><strong>Next</strong>: ten readings on the same spoke for the real scatter, a calibration bench, then a map of all thirty-six spokes. That is the next step.</li>
</ul>
</div>
<hr />
<h3 id="act-i-the-spoke-that-breaks">Act I: the spoke that breaks</h3>
<p>A 2,000 W hub motor is a hub twenty-one centimetres across, laced into a rim with short, thick spokes: 160 mm overall, 2.6 mm in diameter, thirty-six of them, crossed once. When one of them breaks, it is never a surprise for the wheel, only for the rider. This was my second wheel: the first had ended the same way, one spoke, then two, then a rim going out of true beyond recovery.</p>
<p>A spoke almost never breaks because it is too tight. It breaks because it is not tight enough: at every turn of the wheel, when it passes the bottom, the load slackens it, and a spoke that slackens and tightens thousands of times per kilometre ends in fatigue, most often at the elbow, in the flange hole. So the right answer is not to replace the broken spoke; it is to know the tension of the other thirty-five, and to bring them all to the same level, the right one.</p>
<p>The trouble is that nobody could give me that level. Equalising by ear, plucking the spokes, is an old method that works for equalising but says nothing about the absolute value: a whole wheel too tight rings just as nicely as a whole wheel too loose, and on e-bike forums, rims cracked by excess tension are as common as spokes broken for lack of it. I needed a measurement.</p>
<h3 id="act-ii-why-not-a-shop-tension-meter">Act II: why not a shop tension meter</h3>
<p>A classic bicycle tension meter, the 18-euro model as much as the 80-euro one, works by bending the spoke between two supports about sixty millimetres apart with a calibrated spring, and a table converts the deflection into tension. It works well on a 2 mm bicycle spoke 280 mm long, because that spoke, left to itself, hardly resists bending at all: what the device measures really is the tension that stiffens it.</p>
<p>On my 2.6 mm spoke, it is another story. The bending stiffness of a rod grows with the fourth power of its diameter: going from 2 to 2.6 mm nearly triples it. Over a 60 mm span, the share of tension in what the device reads drops below half; the rest is the spoke defending itself, tension or not. And the paper table of these devices stops before 2.6 mm anyway. I would have had a number, but not a measurement.</p>
<p>The solution comes down to two ideas. First, bend over as long a span as possible, because the share of tension grows with the span: over 125 mm instead of 60, it goes from 45 % to over 70 % for a free spoke. Second, replace the spring with a truly repeatable force. I had a 3 to 20 Nm click torque wrench in a drawer. A click is a known torque, the same every time, to within 4 %. With a lever, it is a known force.</p>
<h3 id="act-iii-the-design-and-its-dead-ends">Act III: the design, and its dead ends</h3>
<p>The principle stayed the same from the first sketch to the welded tool: three contact points on the spoke, two on one side at the ends of the span, one in the middle on the other side, carried by an arm that the wrench rotates about a pivot. The wrench clicks, the arm has pushed the spoke with a fixed force, and the deflection it took depends on its tension.</p>
<p>What changed was every detail, and I worked them out on a parametric 3D model, with an AI assistant keeping the model, opening it in Blender and running the numbers at each iteration. A few dead ends are worth telling, because they are instructive.</p>
<p><strong>The first version was 3D printed in plastic.</strong> The calculation killed it before printing: for 1.5 mm of deflection to measure on the spoke, the PLA arm itself bent by 2.5 mm. The whole load path went to steel.</p>
<p><strong>The second bent the spoke out of the wheel plane</strong>, outwards, because it was easier to build. I refused it: a spoke works in the plane of the wheel when the motor pushes, and I wanted to measure it that way. For a round spoke the two are equivalent, but it was not natural, and the pivot would have landed somewhere impossible once the tool sat on a real wheel, with the neighbouring spokes passing 24 mm away.</p>
<p><strong>The supports are bearings, not pins.</strong> The literature on tension meters is clear: friction of the spoke on its supports distorts the reading, and by a lot. Two skate bearings, 22 mm outside diameter, 8 mm bore, cost next to nothing and roll without friction. The third point is a plain fork, because it does not move.</p>
<p><strong>The click releases the load.</strong> It jumped out at me while animating the mechanism in Blender: a click wrench takes about three degrees of play at the moment it trips, and the torque drops. The spoke only needs two degrees of the arm to spring back. Reading the arm&rsquo;s position after the click would read nothing useful. Hence a retained-maximum reading: the depth rod of a caliper, pushed by the arm, stays where the arm brought it when the arm backs off. You read the maximum, the one at the click.</p>
<p><img alt="Top view of the 3D model of the tool sitting on a complete wheel: orange tube above the measured spoke, blue arm, rollers, wrench and caliper" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/08-blender-vue-de-dessus.png" /></p>
<p><img alt="Side view of the model: the spoke horizontal, the two rollers in the plane of the spokes, the tube above, the arm and the wrench head higher still" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/09-blender-vue-de-cote.png" /></p>
<h3 id="act-iv-the-welded-tool">Act IV: the welded tool</h3>
<p>Between the model and the workshop the tool changed again, as it always does when you have steel flats, a welder and a box of bolts at hand rather than the model&rsquo;s exact inventory. The 3D model was then refitted to the real tool, dimension by dimension, photos in hand. Here is what exists.</p>
<p>The frame is a 25 mm square tube that sits above the spoke, 6 mm from it, and stays entirely clear of the neighbouring spokes. Only three elements reach down to spoke level: the fork at the hub end, a 3.5 mm slot hanging under the end of the tube, and the two bearings, each at the end of a vertical bolt. The first bearing, A, sits at the end of a bolt welded under the tube, 125 mm from the fork. The second, B, on the other side of the spoke, 60 mm from A, sits at the end of a bolt welded to the moving arm.</p>
<p>The moving arm is a 4 mm flat lying above the tube. It pivots on a bolt left loose, X, set in a 4 mm plate welded on the tube, 15 mm from A and 46 mm from B. A socket welded on the arm, 25 mm from A, takes the square drive of the torque wrench, whose handle points radially towards the rim. When the wrench pushes, the arm rotates about X and B presses on the spoke with the torque divided by 46 mm: 111 N at 5 Nm, 156 N at 7.</p>
<p>The caliper is slipped between the arm and the tube, beside the pivot plate, and its depth rod touches B&rsquo;s bolt. It reads directly the displacement of B relative to the tube, that is, the deflection of the spoke relative to the chord joining its two fixed supports, A and the fork.</p>
<p><img alt="The tool on the bench, caliper across the tube, roller A and its washer in the foreground" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/01-outil-et-pied-a-coulisse.jpg" /></p>
<p><img alt="The underside of the tool, with the torque wrench engaged in the socket welded on the moving arm" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/02-outil-dessous-cle.jpg" /></p>
<p><img alt="The three contact points marked on the tool: A the roller on the rim side, B the roller of the moving arm, C the fork on the hub side" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/03-outil-lettrage.jpg" /></p>
<p><img alt="A free spoke in the tool: it passes through the slot of the fork, runs along roller B on one side and roller A on the other" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/04-rayon-libre-dans-l-outil.jpg" /></p>
<p><img alt="Side view showing the rollers on their bolts, well below the tube: only the rollers and the fork sit in the plane of the spokes" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/06-galets-sur-entretoises.jpg" /></p>
<p><img alt="Dimensioned elevation of the tool: tube above the spoke, pivot plate, moving arm, rollers A and B, fork C, caliper beam" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/07-schema-elevation.png" /></p>
<h3 id="act-v-the-first-numbers-and-a-mechanics-lesson">Act V: the first numbers, and a mechanics lesson</h3>
<p>First surprise, and the one that taught me the most. On a free spoke, held in the air in the tool, I read 3.4 mm at 3 Nm, 4.6 mm at 5 Nm and 5.9 mm at 7 Nm. Three points perfectly aligned, 0.625 mm per Nm, but a line that does not pass through zero: there is 1.5 mm of reading before the spoke starts to resist. That is the assembly play, the 3.5 mm slot on a 2.6 mm spoke, the loose pivot, the bolts. It is constant, at least those three times, and it subtracts out. But it forbids reading a single measurement without having characterised it.</p>
<p>Second surprise: that slope was two and a half times lower than the calculation predicted for a free 2.6 mm spoke on two supports. I first looked for where the torque was going. It was going nowhere. The beam was wrong: a spoke you hold by the end between two fingers is no longer free, and above all, a spoke mounted in a wheel has its ends held, the elbow in the flange hole 20 mm from the fork, the nipple in the rim 15 mm from roller A. With ends held that close to the supports, the bending stiffness is four times that of a free beam. So the share of tension in what the tool reads falls back to about 40 % around 1,000 N. Less than hoped, but still very measurable.</p>
<p>On the mounted spoke, I read 3.8 mm at 7 Nm. Once the play is subtracted, 2.3 mm of elastic deflection remain, and the model converts them into about 850 N, between 640 and 1,140 depending on whether the play is really 1.2 or 1.8 mm. For a 2.6 mm spoke on a hub motor, the commonly targeted order of magnitude is 1,000 N. So that spoke is not far off, but the figure is still a calculation, not a measurement: the exact position of the supports and how firmly the elbow is held weigh 20 % on the result. To compare spokes with each other, the model is enough. For newtons, it will take a bench.</p>
<p>And the stress in the spoke, because the tool can bend what it measures. The free spoke took 6.7 mm of true deflection over 125 mm at 7 Nm without keeping any set, which corresponds to 1,300 MPa at the surface: the yield point of a drawn spoke, no higher. On a mounted, slack spoke, 7 Nm climbs to 1,500 MPa. <strong>The right setting is 5 Nm</strong>: between 800 and 1,100 MPa whatever the tension, 0.07 mm of reading per 100 N around 1,000 N, and the wrench in the part of its range where its 4 % is guaranteed. Those 4 % of torque read as 8 % of tension; it is the dominant uncertainty, and no lever reduces it. For equalising a wheel, it is enough.</p>
<p>Expected caliper reading at 5 Nm, including the 1.5 mm of play, for a mounted spoke:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Spoke tension</th>
<th>Reading at 5 Nm</th>
</tr>
</thead>
<tbody>
<tr>
<td>0 N</td>
<td>4.57 mm</td>
</tr>
<tr>
<td>400 N</td>
<td>3.68 mm</td>
</tr>
<tr>
<td>800 N</td>
<td>3.19 mm</td>
</tr>
<tr>
<td>1,000 N</td>
<td>3.03 mm</td>
</tr>
<tr>
<td>1,200 N</td>
<td>2.89 mm</td>
</tr>
<tr>
<td>1,600 N</td>
<td>2.68 mm</td>
</tr>
</tbody>
</table></div>
<p>The curve flattens towards high tensions: beyond 1,200 N the tool discriminates poorly. For my wheel, that is not the problem.</p>
<p><img alt="The tool in place on the wheel, torque wrench engaged, handle radial over the rim, caliper across" src="https://pf.olibrio.fr/images/tensiometre-rayons-moteur-roue/05-mesure-sur-la-roue.jpg" /></p>
<h3 id="act-vi-what-remains-to-be-done">Act VI: what remains to be done</h3>
<p>Everything above rests on a handful of readings. The next step is to make the tool talk properly, in this order.</p>
<ol>
<li><strong>Ten readings at 5 Nm on the same spoke</strong>, without touching the nipple, the slider reset to zero between each, the wrench handle always in the same direction. That is the real scatter of the tool, the one that matters.</li>
<li><strong>Five readings at 3 Nm on the same spoke</strong>, to check that the 1.5 mm of play does not move.</li>
<li><strong>A zero-tension control point</strong>, on the free spoke, holding only the tube this time.</li>
<li><strong>A calibration bench</strong>: an extrusion, a spare spoke, a load scale, from 600 to 1,200 N. That is what will turn millimetres into newtons without going through the model.</li>
<li>Then the map of all thirty-six spokes, the search for the target tension, and the replacement of the broken spoke, noting exactly where it broke.</li>
</ol>
<p>If you break spokes on a hub motor, remember this above all: the sound will not give you the level, neither will a classic bicycle tension meter on 12G, and a mounted spoke behaves differently from a free one. The rest is scrap steel, a click wrench and a caliper.</p>]]></content:encoded>
    </item>
    <item>
      <title>A family trailer designed before the first weld: kitchen, gear box, three bikes</title>
      <link>https://pf.olibrio.fr/en/posts/remorque-famille-cuisine-velos.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/remorque-famille-cuisine-velos.html</guid>
      <pubDate>Sat, 19 Sep 2026 12:00:00 +0000</pubDate>
      <description>Designing a home-built trailer towed by a long-wheelbase VW T5: drawer kitchen, fridge and gas, a large box for climbing, surfing and hiking gear, three bikes on the roof. Why the classic Mini axle was undersized, why a Seat Cordoba 1.9D axle replaced it, where to put the axle for 7% nose weight, how long a drawbar makes reversing easy, and why the real motorway risk is crosswind, not snaking.</description>
      <category>trailer</category>
      <category>diy</category>
      <category>blender</category>
      <category>axle</category>
      <category>mini</category>
      <category>seat cordoba</category>
      <category>vw t5</category>
      <category>nose weight</category>
      <category>bike rack</category>
      <category>camp kitchen</category>
      <category>ai</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/og.jpg" alt="A family trailer designed before the first weld: kitchen, gear box, three bikes"></p>
<div class="tldr">
<p><strong>Two minutes, if you want to build a trailer yourself</strong></p>
<ul>
<li><strong>The project</strong>: a trailer behind our long-wheelbase T5. On the ground floor, a drawer kitchen (sink, stove) on one side and the fridge with its gas bottle on the other; above it, a large box for climbing, surfing and hiking gear; on the roof, the family&rsquo;s three bikes.</li>
<li><strong>The method</strong>: draw and calculate everything <strong>before</strong> buying a single tube. The model is a parametric Blender script: change a dimension, rerun, get new pictures.</li>
<li><strong>The bad news</strong>: the Mini axle I had planned on <strong>can&rsquo;t carry the load</strong>. Fully loaded, the trailer puts about 540 kg on the axle, against roughly 450 allowed. See <a href="#essieu">The Mini axle failed the numbers</a>.</li>
<li><strong>The good news</strong>: the Seat Cordoba 1.9D axle I also have at hand carries far more. The first version of this post claimed its coil springs cost no height: that was wrong, they rise up towards the body. Since 21 September the problem has come down to <strong>a single dimension to measure</strong>. See <a href="#correction">Correction: the springs do take up room</a>, then <a href="#cordoba-montage">The Cordoba, down to one dimension</a>.</li>
<li><strong>On the motorway</strong>, snaking isn&rsquo;t the issue — a T5 weighs four times the trailer. <strong>The real risk is crosswind — and it&rsquo;s the bikes, not the body</strong>: on the way home, a 55 km/h gust is enough to unload a wheel when driving at 110 km/h. Figures redone on 21 September with the method used to close bridges to lorries. See <a href="#vent">Wind: three objections, then the bridge method</a>.</li>
<li><strong>For easy reversing</strong>: a 2 m drawbar, and above all <strong>a single beam for the first metre behind the ball</strong>. That, more than the length, keeps the trailer from hitting the T5 when manoeuvring. See <a href="#fleche">A drawbar you can reverse without thinking</a>.</li>
<li><strong>The bikes</strong> don&rsquo;t go up a ramp (it would have been 4.40 m long) but with a <strong>removable mast, a swivelling jib and a pulley</strong>. See <a href="#velos">Lifting three bikes to almost 2 m</a>. <em>Dropped on 21 September: the bikes no longer go up at all.</em></li>
<li><strong>Updates, 21 September</strong>: the wind calculation redone with the bridge method and the Mini axle coming back as a pair (<a href="#maj">first update</a>), then two ideas that change the trailer&rsquo;s shape: the axle sunk into the body and the bikes taken off the roof. <strong>The chosen version is 1.63 m tall instead of 2.92</strong>, and the gust it withstands at 110 km/h goes from 55 to 91 km/h. See <a href="#maj2">the second update</a>.</li>
</ul>
<p><em>Nothing is built yet. This post is about the phase where anything you break only costs computing time.</em></p>
</div>
<p><img alt="3D render of a closed sage-green trailer, three bikes on the roof, long drawbar with a dismantled mast stowed on it" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/01-fermee-velos.jpg" /></p>
<p><em>The 19 September version, closed, bikes loaded. Cordoba axle, 2.30 × 1.61 m body, 2 m drawbar with the lifting mast dismantled and laid on it. The one I finally chose is <a href="#retenue">further down</a>: it is 1.30 m lower.</em></p>
<h2 id="the-brief-fits-in-one-sentence">The brief fits in one sentence</h2>
<p>When we go climbing, surfing or hiking as a family, the T5 fills up fast. The idea: take everything bulky out of the cabin — kitchen, fridge, gear, bikes — and put it in a trailer designed for it, where everything has its place and opens without unpacking the rest.</p>
<p>I described it in one go, roughly like this: a Mini axle; on level zero, kitchen drawers on one side (sink and small stove), the fridge and its gas bottle on the other; on the level above, a large box for climbing gear, surfboards and walking poles, closed by a lid on boot hinges held open by gas struts; and on top, three slots for our three bikes, loaded by a removable ramp stowed under the trailer.</p>
<p>An AI (Claude) turned that sentence into a 3D model: a Python script that builds the trailer piece by piece in Blender, with every dimension at the top of the file. I never opened Blender to draw it. I looked at the pictures, corrected, clarified — and it was precisely when we moved from pictures to numbers that the project changed.</p>
<h2 id="three-floors-one-per-use">Three floors, one per use</h2>
<p>The principle is a chest of drawers: heavy things at the bottom, bulky things in the middle, light and voluminous things on top.</p>
<p><img alt="Side view of the trailer with dimensions in orange: floor at 0.75 m, top of the kitchen level at 1.41 m, lid at 1.88 m, bikes up to 2.93 m; 2.30 m body, 2.00 m drawbar, 3.10 m from ball to axle" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/02-profil-cote.jpg" /></p>
<p><em>The main dimensions of the chosen version (the labels on the image use a decimal comma). The floor is high (0.75 m) because the body sits above the wheels: that is what allows a body wider than the track.</em></p>
<p><strong>The ground floor, kerb side, is the kitchen.</strong> A flap lifts up and becomes an awning. Below it, two low drawers slide out: one carries the sink and its tap, the other a two-burner stove. Their top is the worktop, <strong>0.95 m off the ground</strong>, standard kitchen height. That is no accident: the height of these drawers is derived from the floor height, which depends on the axle. Change the axle, the worktop stays at 0.95 m. Above, two drawers for dishes and groceries; at the end, a column for two 20 L jerrycans, a 12 V pump and the battery.</p>
<p><img alt="The trailer's kitchen side, flap raised as an awning, sink drawer and stove drawer pulled out, water column open, bikes on the roof" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/03-cuisine.jpg" /></p>
<p><em>Kitchen side, bikes in place. Everything opens without unloading the roof.</em></p>
<p><strong>On the other side, fridge and gas.</strong> A 40 L three-way fridge (12 V, 230 V, gas) on a pull-out slide, so it opens from the top once out. At the front, a locker sealed from the inside and vented to the outside — high and low grilles on the door, a grille in the floor, because propane is heavier than air — for a 6 kg bottle. At the back, the folding table and chairs.</p>
<p><img alt="The trailer's fridge side, flap raised, fridge pulled out on its slide, gas locker door open on a blue bottle" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/04-frigo-gaz.jpg" /></p>
<p><em>Fridge side. The bottle is separated from the rest by a bulkhead, with its own ventilation.</em></p>
<p><strong>Above, the box.</strong> 2.26 m long inside: room to lay a 7&lsquo;0 surfboard, a crash pad, ropes, bags and poles. The lid pivots on three boot hinges and stays open at 82° on two gas struts.</p>
<p><img alt="Looking down into the open box: surfboard, crash pad, coiled ropes, dry bag; lid raised and held by two struts" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/05-bac-ouvert.jpg" /></p>
<p><em>The open box, without the bikes — and that is not a staging choice.</em></p>
<p>It was the first thing the model taught me, before any calculation: <strong>the bikes sit on the lid</strong>. As long as they&rsquo;re there, the box doesn&rsquo;t open. The kitchen and fridge do. It&rsquo;s a constraint to live with (load the box before the bikes) or to design around (a fixed rack on posts and side hatches), but not something to discover in the car park at the crag.</p>
<p><em>Update, 21 September: constraint lifted. The bikes have left the lid, and the box opens with the bikes loaded — see <a href="#velos-bas">The bikes come down from the roof</a>.</em></p>
<h2 id="the-first-draft-looked-good-and-proved-nothing">The first draft looked good and proved nothing</h2>
<p>The first version produced clean pictures in one evening. Mini axle, 2.30 × 1.45 m body, 1.35 m drawbar, axle placed &ldquo;a bit behind the middle to get some weight on the ball&rdquo;, a four-section telescopic ramp for the bikes. Everything looked plausible.</p>
<p>And that is exactly the problem: <strong>a clean render looks like a validation</strong>. Nothing in those pictures said how much the trailer weighs, what the axle can carry, or how it behaves at 130 km/h behind a van. So I asked what a trailer builder would ask: a proper weight distribution, and a drawbar long enough to manoeuvre easily with my long T5.</p>
<p>The calculation is a second script, independent of Blender. It takes every component — frame, drawbar, panels, drawers, fridge, water, gas, gear, bikes — with its estimated mass and position, and derives the centre of gravity and the load on the ball and on the axle, for five loading cases: empty; loaded without bikes; loaded with bikes; on the way back, water and food used up; and with e-bikes.</p>
<p>First result: <strong>the trailer weighs 416 kg empty</strong> in its lightest version (50×30 tube frame, 15 mm poplar plywood). Loaded with the bikes, 582 kg. The very first ballpark estimate, made before this calculation, said 260 to 400 kg empty: the bottom of that range was very optimistic.</p>
<h2 id="essieu">The Mini axle failed the numbers</h2>
<p>My axle is the rear suspension of a classic Mini: a subframe, two trailing arms, rubber cones for springs, 10-inch wheels. It&rsquo;s compact and light. It&rsquo;s also the rear axle of a 650 kg car, normally carrying 250 to 300 kg — and something like 450 kg at most.</p>
<p>Loaded with the bikes, the trailer puts <strong>541 kg on its axle</strong>; 563 kg with e-bikes. That&rsquo;s 20 to 25% over what the subframe is meant to carry, permanently, on the motorway. Even stripping out everything that can be lightened, you don&rsquo;t get under the limit with a kitchen, a fridge, 40 L of water and three bikes.</p>
<p>I had two other options at hand or within reach: a Peugeot 205 or 206 rear axle, and the one from a <strong>Seat Cordoba 1.9D</strong> (first generation). The calculation puts them all through the same test:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th></th>
<th>Mini</th>
<th>205</th>
<th>206</th>
<th>Cordoba 1.9D</th>
</tr>
</thead>
<tbody>
<tr>
<td>Tyres</td>
<td>145/80 R10</td>
<td>165/70 R13</td>
<td>175/65 R14</td>
<td>175/70 R13</td>
</tr>
<tr>
<td>Track</td>
<td>1.17 m</td>
<td>1.30 m</td>
<td>1.43 m</td>
<td>1.40 m</td>
</tr>
<tr>
<td>Axle load, loaded + bikes</td>
<td>541 kg</td>
<td>568 kg</td>
<td>586 kg</td>
<td>584 kg</td>
</tr>
<tr>
<td>Max rear axle load (order of magnitude)</td>
<td>~450 kg</td>
<td>~600 kg</td>
<td>~700 kg</td>
<td>~750 kg</td>
</tr>
<tr>
<td>Lateral acceleration before rollover</td>
<td>0.57 g</td>
<td>0.60 g</td>
<td>0.65 g</td>
<td>0.64 g</td>
</tr>
</tbody>
</table></div>
<p><em>Maximum loads are orders of magnitude; the actual &ldquo;rear axle&rdquo; figure is on the donor car&rsquo;s manufacturer plate.</em></p>
<p><img alt="Four views from below of the same trailer on four different axles: Mini subframe with cones, 205 torsion bars with horizontal dampers, 206 torsion bars, Cordoba twist beam with red coil springs sitting on the arms" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/06-essieux.jpg" /></p>
<p><em>The same trailer on the four axles, generated by changing a single variable. The body widens by itself to cover the wheels. The Cordoba is drawn with separate springs sitting on the arms: that isn&rsquo;t how the car does it (see <a href="#correction">the correction</a>).</em></p>
<p>On paper, the Cordoba wins: the biggest load margin, 13-inch wheels that take potholes better and run their bearings cooler at 130, a wide track. And its suspension should work within its original range: the rear of a diesel Cordoba is designed for loads of the same order as the trailer&rsquo;s (430 to 610 kg depending on the load). A bonus I hadn&rsquo;t seen coming: on this kind of axle, the beam joining the two arms twists when one wheel rises and the other doesn&rsquo;t — it acts as an <strong>anti-roll bar</strong>, exactly what you want with bikes perched up high.</p>
<h3 id="correction">Correction: the springs do take up room</h3>
<p><em>Added on 19 September 2026, the day of publication.</em></p>
<p>The first version of this post said here that the Cordoba&rsquo;s coil springs &ldquo;don&rsquo;t cost a single centimetre&rdquo;: they would sit between the suspension arm and the underside of the frame, in the thirty-odd centimetres the tyre leaves free under the floor anyway. That&rsquo;s what the render above shows, with its red springs.</p>
<p>Rereading it, something didn&rsquo;t sit right: on the car, the spring rises up into the body, well above the wheel. A workshop guide for the Polo 6N, which shares its platform with the Cordoba 6K, confirms it. <strong>The spring wraps around the damper</strong>, the two form a single unit, and <strong>its top bolts into the boot</strong>, at the top of the wheel arch behind the trim. At the bottom, a horizontal eye bolts it to the beam, near the hub. The AI had reasoned about a separate spring sitting on the arm, as found on other axles of this type, without checking this particular car. And the clean render made the assumption look credible: exactly the trap described below.</p>
<p>The consequence: between the lower eye and the underside of the frame there are about 38 cm, and the unit is clearly longer. It would poke into level 0, right under the stove drawer on one side and the fridge on the other. Four ways out, not yet decided:</p>
<ul>
<li><strong>separate spring and damper</strong>: keep the Cordoba&rsquo;s beam and arms, sit the spring alone in a seat on the arm (bolted rather than welded, on a suspension part) and another under the frame, with a short damper mounted separately;</li>
<li><strong>turrets in level 0</strong>, rearranging the kitchen and the fridge side around the axle;</li>
<li><strong>raise the floor</strong> by the overhang: the simplest, but the bikes and the centre of gravity go up by the same amount, and crosswind is already the weak point;</li>
<li><strong>go back to the 205 axle</strong>, whose torsion bars sit inside the tube and whose dampers lie flat: no height problem at all, but a thin load margin with e-bikes.</li>
</ul>
<p>To choose, I need three measurements on the axle: the length of the unit as fitted, the free length of the spring alone, and the height of the lower eye relative to the wheel centre.</p>
<p><em>Update, 21 September: the calculation has since reduced those three measurements to one. See <a href="#cordoba-montage">The Cordoba, down to one dimension</a>.</em></p>
<h2 id="autoroute">Holding the road behind the T5</h2>
<p>The rule you hear everywhere: weight on the ball, or the trailer snakes. You still need to know how much. I aimed for <strong>7% of the mass on the ball</strong> when fully loaded. The calculation then puts the axle 1.20 m from the back of the body — 5 cm ahead of the middle, where the first draft had put it 17 cm behind, by eye. Depending on the load, the nose weight stays between 5.8 and 7.6%, i.e. 31 to 45 kg, well below the 100 kg the T5&rsquo;s tow bar accepts.</p>
<p>For snaking itself, the calculation uses a classic vehicle-dynamics model: car and trailer seen from above, joined by the ball, each with its mass, inertia and tyres that slip sideways. You look for the speed at which a small oscillation grows instead of dying out.</p>
<p>Result: <strong>above 400 km/h</strong>. In every loading case, on every axle, and even with the axle placed for zero or negative nose weight.</p>
<p>A result that comfortable makes me suspicious. A model that says &ldquo;all good&rdquo; whatever you give it may well be a model that computes nothing. So we tested it on a case whose answer is known: a 1.5 t caravan behind a 1.4 t saloon. The same model has it losing control at around 160 km/h with 5% nose weight, 110 km/h with 3%, 80 km/h with none — which matches what you read everywhere about caravans. So the model can predict instability; if it finds none here, it&rsquo;s because <strong>the T5 weighs four times the trailer</strong>. The dog wags the tail, not the other way round.</p>
<p>The real risk is elsewhere, and much more mundane: <strong>crosswind</strong>. Three bikes nearly 3 m up, a 1.20 m high flank, a 1.40 m track. On the way back, with water and food used up, <strong>a 55 km/h crosswind gust is enough to unload the windward wheel when driving at 110 km/h</strong>; in a curve, about thirty. <em>(Correction, 21 September: the first version said &ldquo;about 100 km/h&rdquo;. That figure came from a home-made model, and it only holds for a parked trailer. The story of this correction is told <a href="#vent">further down</a>.)</em> The rule follows: with strong wind forecast, drive slower, or the bikes travel in the T5.</p>
<h2 id="fleche">A drawbar you can reverse without thinking</h2>
<p>Reversing a short trailer is a test of nerves: it jackknifes before you understand why. What matters is the ratio of two lengths: the distance from the ball to the trailer axle, and from the T5&rsquo;s rear axle to the ball (about 1.10 m on a long T5). The bigger the ratio, the slower the trailer reacts, and the more time you have to correct.</p>
<p>With a 2 m drawbar, the ball is <strong>3.10 m from the axle</strong>: a ratio of 2.8. Going longer gains little (3.0 with a 2.20 m drawbar) and costs length (4.35 m overall, already 9.60 m for the whole rig with the T5).</p>
<p>Two calculation errors along the way were more instructive than the right answers. The first version announced that at full lock the trailer would sit at <strong>164°</strong> to the T5 — in other words, overtaking it. A rotation in the wrong direction; once fixed, the angle drops to 51°. The second announced contact between trailer and T5 at 55°, whatever the drawbar length. Suspicious: if the length changes nothing, it isn&rsquo;t what&rsquo;s limiting. It was the coupling head itself, 25 cm from the ball — which in reality passes <strong>under</strong> the bumper, like on any trailer. With that point removed, the real limit showed up: how wide the V of the drawbar opens near the ball.</p>
<p>Hence the chosen shape: <strong>a single central beam for the first metre behind the ball</strong>, with the V opening only beyond it. First contact with the T5 then moves past 100° of articulation. At full lock, only 51 are used.</p>
<p><img alt="Top view: the T5 at full lock, the trailer behind at 51 degrees, well clear of the back of the van" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/07-braquage.jpg" /></p>
<p><em>At full lock, steady state: 51° between T5 and trailer, with a good margin before any contact. The trailer cuts the corner about 1 m inside the path of the T5&rsquo;s rear wheels.</em></p>
<p>A detail the model makes obvious and I wouldn&rsquo;t have thought of: the trailer (1.61 m) is narrower than the T5 (1.90 m). Driving straight, <strong>you can&rsquo;t see it in the mirrors</strong>. A wireless reversing camera, or two marker posts on the rear corners, are part of the project.</p>
<p><img alt="Three-quarter rear view of the trailer hitched to the T5, bikes rising above the van's roof" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/08-attelee-t5.jpg" /></p>
<p><em>Hitched. The bikes stand a metre above the T5&rsquo;s roof: worth remembering at car park height barriers.</em></p>
<h2 id="velos">Lifting three bikes to almost 2 m</h2>
<p><em>Update, 21 September: this section describes a solution dropped two days later. I&rsquo;m leaving it in, because the reasoning that kills the ramp still holds — and because the right answer was to stop lifting the bikes at all. See <a href="#velos-bas">The bikes come down from the roof</a>.</em></p>
<p>The ramp from my original idea died of simple trigonometry. The bikes sit at nearly 1.90 m; for a 25° slope, already steep for pushing a bike, you need a ramp <strong>over 4.40 m</strong> long — four telescopic sections, a prop in the middle so it doesn&rsquo;t flex, and a 25 kg e-bike to push at arm&rsquo;s length. Doable by two people with regular bikes, painful otherwise.</p>
<p>The alternative came from me, and it&rsquo;s the one we kept: <strong>a removable mast, a jib that swivels at the top, a pulley at the end</strong>. The mast, two 1.50 m aluminium sections, slots into a sleeve welded onto the drawbar beam, just in front of the body. The jib, with a 1.45 m reach, covers all three rails plus a pick-up point on the ground beside the trailer. A hand winch on the mast, a cable over a pulley at the top then over the one at the end of the jib, and a spreader bar hooked to the bike&rsquo;s saddle and stem so it goes up level.</p>
<p><img alt="The jib in action: mast planted in front of the body, jib swung toward the kitchen side, a blue bike hanging from an orange spreader bar halfway up, two other bikes already in place" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/09-potence.jpg" /></p>
<p><em>The right-hand bike being lifted. Dismantled, the mast and jib lie on the drawbar (visible in the other views).</em></p>
<p>Two constraints came out of the model. Height first: for a bike sitting in its rail to pass under the pulley, spreader bar included, the mast has to reach 3.45 m. And loading order: <strong>middle bike first</strong>. Otherwise you&rsquo;d have to lift it over another one, and no reasonable mast goes that high. Finally, something better written down than assumed: you don&rsquo;t lift anything on an unhitched, poorly chocked trailer.</p>
<h2 id="maj">Update, 21 September: the Mini comes back as a pair, and the wind goes via the library</h2>
<p><em>Two days after publication. Nothing is built, so everything can still move — and it did.</em></p>
<h3 id="tandem">What about two Mini axles?</h3>
<p>After the post went out, someone put to me an idea I had dismissed too quickly: since one Mini subframe isn&rsquo;t enough, <strong>fit two, one behind the other</strong>, with 5 cm wheel spacers. The case took one line: double the load, much better road holding, much more resistance to crosswind.</p>
<p>Adding the variant to the model took one line of parameters, like the other axles. What remained was to put a number on each claim.</p>
<ul>
<li><strong>Load: true, and better than expected.</strong> Each subframe carries 290 kg, or 145 kg per wheel — about what a Mini&rsquo;s rear wheel carries on the road. So each rubber cone works where it was designed to work. The margin goes from −25% with a single subframe to <strong>+33%</strong>, e-bikes included.</li>
<li><strong>Road holding: true, for a precise reason.</strong> Two axles 80 cm apart resist the trailer rotating about a vertical axis (<em>yaw</em>) far more than one does: the restoring effect of the tyres is <strong>multiplied by 4.7</strong>. That is what settles a trailer after a gust or a passing lorry.</li>
<li><strong>Crosswind: a draw.</strong> Overturning depends on two things only, track width and centre-of-gravity height. The tandem gains because its 10-inch wheels allow a floor 11 cm lower, and loses as much because its track stays narrower than the Cordoba&rsquo;s.</li>
<li><strong>&ldquo;The load is doubled&rdquo;: yes for the axle, no for the trailer.</strong> What caps the payload is the gross weight declared at approval: above 750 kg you need brakes and a different licence. The tandem buys margin and service life, not extra kilos to carry.</li>
</ul>
<p><img alt="Underside view of the trailer on two Mini subframes in tandem: four small 10-inch wheels, two black cross-members, the body sitting lower than on the other versions" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/11-tandem-mini.jpg" /></p>
<p><em>The twin Mini subframe variant. The rest of the model adapted by itself: 1.46 m wide body, floor at 0.64 m, bikes at 2.82 m instead of 2.93.</em></p>
<p>And it has a price. <strong>Tyre scrub</strong>, first: to pivot, a twin-axle trailer has to drag its tyres sideways. On full lock it takes 52 kg of side push at the tow ball. The T5 won&rsquo;t notice; I will, the day I want to shift it by hand on a campsite pitch. Then a known flaw of this layout, which I found described by axle manufacturers: two independent suspensions <strong>don&rsquo;t share the load by themselves</strong>. The height of the T5&rsquo;s tow ball sets the trim — 5 cm off, and the front axle loses 51 kg while the rear takes on 39 — and over a bump a single axle takes almost everything. Manufacturers derate these axles by about 20% when fitting them in pairs; here each one works at 65% of its rating, so the rule is met. Finally, 43 kg more unladen weight, and five 10-inch tyres to find, a size that has become rare.</p>
<h3 id="vent">Wind: three objections, then the bridge method</h3>
<p>The crosswind passage <a href="#autoroute">above</a> was wrong. Not in its conclusion — the bikes on the roof remain the weak point — but in its numbers. The story of the correction taught me more than the correction itself.</p>
<p>The first calculation was <strong>a model hand-written by the AI</strong>: wind pushing on the trailer&rsquo;s flank as on a panel. I balked three times.</p>
<p><em>&ldquo;I&rsquo;ve seen tall trailers with no suspension that don&rsquo;t suffer from wind.&rdquo;</em> Checked: the same body <strong>without the bikes</strong> takes a much stronger gust. The problem isn&rsquo;t the height of the body, it&rsquo;s the bikes: perched at 2.30 m, they alone account for 41% of the effort trying to lay the trailer on its side. Along the way the model turned out to be too optimistic. It forgot that a moving trailer doesn&rsquo;t get the wind from the side but from the front quarter, added to its own speed, and that a box taken at an angle is pushed much harder than a panel taken square-on.</p>
<p><em>&ldquo;A bike isn&rsquo;t a solid panel.&rdquo;</em> Correct. Counted part by part — tyres, rims, spokes, frame — a bike presents only 34% of its silhouette to the wind. And air passes through it: it suffers drag, not the wing effect the body suffers. The two downwind bikes are also sheltered by the first.</p>
<p><em>&ldquo;You&rsquo;re forgetting the T5 in front, clearing the way.&rdquo;</em> There I was surprised to be the one who thought of it. Answer: the T5 does shelter the trailer… in light wind. Its wake leaves with the wind, not along the road. At 110 km/h, the shelter is complete below 20 km/h of crosswind, half gone at 40, almost nil at 60. And the bikes stick out above its roof anyway.</p>
<p>Three corrections for three objections: we were groping. So I asked what I should have asked from the start — <strong>how do the people whose job this is do it?</strong> Ten minutes of searching were enough. The method exists, published and open access: Baker and Soper (2018), used to decide at what gust speed a bridge gets closed to lorries. It fits in three lines, it rests on coefficients <strong>measured</strong> in wind tunnels and at full scale, and it already contained my third objection: a trailer measured behind its lorry only gains about 10% of shelter.</p>
<p>Redone with that method, on the way home with bikes on the roof: <strong>a 55 km/h gust is enough to unload the windward wheel when driving at 110 km/h</strong>, 61 km/h when driving at 90. Trailer full, no bikes: 85. These are wind speeds at trailer height; forecasts give gusts measured 10 m up, and over open ground the wind is about a quarter weaker down at road level. The method ranks every vehicle by a &ldquo;characteristic velocity&rdquo;: with its bikes, my trailer is more vulnerable than an <strong>empty double-decker bus</strong>, the first vehicle to be stopped when the wind rises on the Queensferry Crossing in Scotland. The practical rule: from 80 km/h forecast gusts, slow to 90, or the bikes travel in the T5.</p>
<p>The home-made model gave 58 km/h instead of 55: the right order of magnitude, reached after three patches. One part of the calculation is still my own, and is flagged as such: the treatment of the bikes, for want of published measurements of bikes sitting on a roof.</p>
<h3 id="limites">When it lets go</h3>
<p>What I really wanted to know wasn&rsquo;t what the highway code says: it was <strong>where the real limits are</strong>. The calculation yields three, gathered on one chart.</p>
<p><a href="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/10-limites.png"><img alt="Three charts: the limiting gust against road speed for three loadings, overturning lateral accelerations compared with tyre grip, and snaking damping against speed" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/10-limites.png" /></a></p>
<p><em>The three ways of letting go, for the twin Mini subframe variant (chart labels in French: vent = wind, virage = cornering, louvoiement = snaking). The Cordoba gives the same curves within a few km/h. Click to enlarge.</em></p>
<ul>
<li><strong>Wind.</strong> Full and without bikes, nothing lets go in normal weather: it would take 110 km/h forecast gusts to worry it at 130 km/h. With the bikes, the margin melts.</li>
<li><strong>Cornering.</strong> The trailer tips over at between 0.56 and 0.64 g of lateral acceleration, depending on the load. That is before the tyres slide on a dry road (0.8 g), but after on a wet one (0.5 g). In other words: in the dry, it goes over without having slid; in the wet, the outfit slides first. With the bikes, 0.58 g means 61 km/h on a slip road of 50 m radius.</li>
<li><strong>Snaking.</strong> No critical speed behind the T5, even with 120 kg badly placed right at the back. The green curve is the control case, a 1.5 t caravan behind a saloon of the same weight: it drops below zero at around 115 km/h.</li>
</ul>
<h3 id="cordoba-montage">The Cordoba, down to one dimension</h3>
<p>That left the problem opened by <a href="#correction">the correction</a>: the spring-and-damper unit rising towards the body. Looking at what sits at that spot in the trailer, the constraint first tightened: the stove drawer on one side and the fridge slide on the other <strong>sweep the floor</strong> right above it. Nothing may protrude, not even a bulge.</p>
<p>Then the calculation loosened it. Under the trailer&rsquo;s load (290 kg per wheel), the original spring compresses and the unit is only about 41 cm long. Its head can then sit <strong>within the depth of the frame</strong>, 45 mm below the floor, on a plain drilled plate welded between two rails.</p>
<p>On one condition, and it is the only unknown left: that the unit&rsquo;s lower eye sits less than 1.5 cm above the wheel centre. I assumed 3 cm below; it takes five minutes to measure on the car, before dismantling it. If the dimension turns out wrong, there are height-adjustable coilovers approved for this car.</p>
<p><img alt="Underside view of the trailer on the Cordoba axle: the red and yellow coilover rises to the frame, two black brackets drop from the frame to the trailing-arm pivots, joined by a tube" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/12-cordoba-montage.jpg" /></p>
<p><em>The Cordoba mounting as the calculation now draws it: the coilover stops inside the frame, and two brackets drop down to reach the arm pivots.</em></p>
<p>The real fabrication work is elsewhere, and I hadn&rsquo;t seen it. Because the body sits above the wheels, the frame is 67 cm off the ground while the axle&rsquo;s arm pivots are at 33 cm. So it takes two <strong>35 cm drop brackets</strong>, which see about 1.6 kN·m on a pothole taken fast: box sections in 5 mm plate, a brace running forward, and a tube between the two to carry side loads.</p>
<p>This mounting is still far simpler than the tandem: one axle, four fixing points, nothing to adjust, tyres you find everywhere, and a trailer you can still move by hand. The tandem has more margin and a lower floor. I haven&rsquo;t decided.</p>
<h2 id="maj2">Second update, 21 September: everything comes down</h2>
<p><em>The same day, a few hours later. Two design questions did more for the trailer than all the morning&rsquo;s calculation refinements.</em></p>
<h3 id="surbaissee">Sinking the axle into the body</h3>
<p>From the start, the body sat <strong>above</strong> the wheels. That is what let it be wider than the track, and also what perched the floor at 75 cm. Hence my question: why not do as the Seat itself does, or any van? Drop the floor between the wheels, let them rise into <strong>wheel arches</strong> inside the body, and build the drawers around them.</p>
<p>The floor can come down until it nearly brushes the beam joining the two wheels: 0.51 m instead of 0.75. Everything else follows as one block, 24 cm lower. And the two difficulties of the mounting, the ones from this morning&rsquo;s update, vanish at a stroke:</p>
<ul>
<li>the <strong>35 cm brackets</strong> that had to drop from the frame down to the arm pivots shrink to 10: the pivots bolt almost directly under the rails, as on the car;</li>
<li>the <strong>head of the coilover</strong> no longer has to fit under a floor. It rises into the wheel arch, exactly as it does in the Seat&rsquo;s boot. The famous dimension to measure no longer decides anything: it merely sets the height of the wheel arch.</li>
</ul>
<p>The price is joinery and frame work. Two wheel arches of 73 × 36 cm, 20 cm high, take about a hundred litres out of 2,400. Each side flap gets a notch at the wheel. And the frame is no longer a rectangle: the wheel is in the way of the outer rails, so two rails run inboard of the wheels, and cross-members carry the sides ahead of and behind them.</p>
<p>The layout reorganises itself around a simple rule: <strong>whatever needs little height goes above the wheel, whatever needs a lot goes in front or behind.</strong> The stove drawer is 22 cm high. It was already level with the axle; it stays there, sitting on the wheel arch. The sink gains a deep drawer, with room for a grey-water can beneath. The fridge leaves the axle line for the rear — and since everything has dropped, its lid goes from 1.27 m to 1.03 m: you can finally reach into it.</p>
<p><img alt="The lowered trailer on the kitchen side, flap raised: deep sink drawer at the rear, shallow stove drawer right above the wheel, tall water column at the front" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/13-cuisine-surbaissee.jpg" /></p>
<p><em>Kitchen side. The stove drawer sits on the wheel arch; the sink and the water column, either side, go down to the new floor.</em></p>
<p><img alt="The lowered trailer on the fridge side: fridge pulled out on its slide at the rear, table and chairs stowed flat above the wheel, gas locker at the front" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/14-frigo-surbaisse.jpg" /></p>
<p><em>Fridge side. The fridge has moved to the rear; above the wheel, whatever stows flat.</em></p>
<p>On the road: the centre of gravity drops by 21 cm, cornering rollover goes from 0.58 to 0.71 g, and the limiting gust at 110 km/h from 55 to 66 km/h.</p>
<p>The idea doesn&rsquo;t combine with the morning&rsquo;s. With two Mini axles, the wheel arches would be 1.45 m long, 63% of each side: there would be almost no low drawers left.</p>
<h3 id="velos-bas">The bikes come down from the roof</h3>
<p>Second question: why insist on lifting three bikes to nearly 2 m? They could sit on a carrier behind the trailer. Or two behind and one in front, on the drawbar.</p>
<p>For wind, it&rsquo;s no contest. Set crosswise a metre off the ground, the bikes are hit <strong>end-on</strong> by a crosswind, and they are no longer perched: they hardly count in overturning any more. But it is the <strong>nose weight</strong> that separates the locations, and there the calculation didn&rsquo;t say what I expected.</p>
<p><a href="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/16-velos.png"><img alt="Three charts: the limiting gust against speed for four combinations, nose weight by bike location, and overall height" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/16-velos.png" /></a></p>
<p><em>In the middle, nose weight for each location: one dot per loading case, between the 4% and 10% bounds (chart labels in French). Click to enlarge.</em></p>
<ul>
<li><strong>Three bikes at the rear</strong>: 40 to 60 kg a metre and a half behind the axle. Depending on whether the trailer is full or empty, with or without bikes, nose weight ranges from 3 to 10% of the mass. Too little on the way home with e-bikes, too much when driving without bikes. It is exactly what caravanners hold against rear bike racks.</li>
<li><strong>Three bikes on the drawbar</strong>: the opposite flaw. The ball unloads as soon as you drive without bikes.</li>
<li><strong>Two behind, one in front</strong>: the front bike balances the rear ones. Nose weight stays between 5 and 8% in every case, as well-behaved as with the bikes on the roof. The heaviest goes in front.</li>
</ul>
<p>Snaking doesn&rsquo;t move. Three bikes right at the back raise the trailer&rsquo;s rotational inertia by 17%, and behind a T5 four times heavier that changes nothing. Behind a saloon it would be another story.</p>
<p>The carrier I&rsquo;ll build myself, and it is restfully simple: <strong>a platform</strong>, and <strong>a clamp sliding on a rail along the trailer&rsquo;s centreline</strong>. Set the bikes crosswise, push the clamp in until the frames are pressed against bumpers fixed to the body, pin it, padlock it — the clamp doubles as an <strong>anti-theft device</strong> — then strap the wheels. The model brought out two details. The handlebars have to be turned a quarter turn. And even with the cranks vertical, the pedal sticks out 14 cm: so the bumpers stand 15 cm off the body, otherwise the pedal touches before the frame does.</p>
<p><img alt="Rear view: two bikes crosswise on a platform, an upright clamp with two rubber jaws pressing them against the body, padlock on the slider, light bar and number plate under the platform" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/17-porte-velos-arriere.jpg" /></p>
<p><em>The rear carrier. The clamp slides on the drilled rail, on the centreline; orange pin and padlock on the slider; orange straps on the wheels; lights and plate repeated behind the bikes.</em></p>
<p><img alt="Front view: one bike crosswise on a platform resting on the drawbar, between the body and the jockey wheel, held by the same clamp" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/18-porte-velos-timon.jpg" /></p>
<p><em>The same principle on the drawbar, for the heaviest bike. On full lock it stays well clear of the T5.</em></p>
<p>What this changes in use matters more than the numbers. The mast, jib and winch of the section <a href="#velos">Lifting three bikes to almost 2 m</a> no longer exist: a bike is loaded at waist height. And the model&rsquo;s first lesson — <em>the bikes sit on the lid, as long as they&rsquo;re there the box doesn&rsquo;t open</em> — falls too.</p>
<p><img alt="View from above: the box lid wide open while two bikes are in place at the rear and one at the front" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/19-bac-ouvert-velos.jpg" /></p>
<p><em>The box opens with the bikes loaded.</em></p>
<h3 id="retenue">The chosen version</h3>
<p>This time I&rsquo;ve decided: <strong>Cordoba axle, lowered body, two bikes behind and one on the drawbar.</strong></p>
<p><img alt="The chosen trailer hitched to the T5: low body, two bikes at the rear, the whole outfit below the van's roofline" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/20-retenue-attelee.jpg" /></p>
<p><em>Hitched. The whole outfit passes under the T5&rsquo;s roofline.</em></p>
<div class="table-scroll"><table>
<thead>
<tr>
<th></th>
<th>19 September project</th>
<th>Chosen version</th>
</tr>
</thead>
<tbody>
<tr>
<td>Floor</td>
<td>0.75 m</td>
<td>0.51 m</td>
</tr>
<tr>
<td>Overall height, bikes loaded</td>
<td>2.92 m</td>
<td>1.63 m</td>
</tr>
<tr>
<td>Overall length</td>
<td>4.35 m</td>
<td>5.09 m</td>
</tr>
<tr>
<td>Cornering rollover</td>
<td>0.58 g</td>
<td>0.79 g</td>
</tr>
<tr>
<td>Gust withstood at 110 km/h, on the way home</td>
<td>55 km/h</td>
<td>91 km/h</td>
</tr>
<tr>
<td>Nose weight, all loadings</td>
<td>5.5 to 7.6%</td>
<td>5.1 to 8.2%</td>
</tr>
<tr>
<td>To load a bike</td>
<td>a 3.45 m mast, a jib, a winch</td>
<td>put it down</td>
</tr>
</tbody>
</table></div>
<p>At 0.79 g, the rollover threshold meets the grip limit of the tyres on a dry road: the trailer would slide before tipping. And at 1.63 m, the whole outfit passes under the T5&rsquo;s roof, hence under the same car-park barriers it does.</p>
<p>The price: 74 cm more length, lights and a plate to repeat behind the bikes, bikes exposed to road spray, and a frame more complicated to weld than a rectangle.</p>
<p><a href="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/15-architecture.png"><img alt="Three charts comparing the body over the wheels and the lowered body: limiting gust, cornering rollover, height" src="https://pf.olibrio.fr/images/remorque-famille-cuisine-velos/15-architecture.png" /></a></p>
<p><em>Body over the wheels versus lowered body, for the Cordoba and for the two Mini axles (chart labels in French). Click to enlarge.</em></p>
<h2 id="what-i-take-away">What I take away</h2>
<p><strong>A render is not a check.</strong> The first pictures were convincing and rested on an axle 20% overloaded. What moved the project forward wasn&rsquo;t the 3D model, it was the question &ldquo;how much does it weigh, and where&rdquo;.</p>
<p><strong>Distrust a result that&rsquo;s too comfortable.</strong> &ldquo;Stable above 400 km/h&rdquo; could have been a bug. The only way to know was to run the same calculation on a case with a known answer. Same reflex as with dimensions that add up: consistency isn&rsquo;t proof.</p>
<p><strong>Check against the real part.</strong> The spring mistake came neither from a calculation nor from a render, but from a generalisation (&ldquo;on this kind of axle, the spring sits on the arm&rdquo;) never checked against the actual car. Remembering the car was enough to spot it, and a workshop guide confirmed it.</p>
<p><strong>An absurd error is good news.</strong> 164° of articulation, you spot it immediately. The dangerous errors are the ones that give a plausible number — like those 55° that didn&rsquo;t depend on drawbar length, which only that detail gave away.</p>
<p><strong>Parametrise everything.</strong> Switching axles took one line: the body widened, the floor rose, the kitchen drawers got shorter to keep the worktop at 0.95 m, and the calculation moved the axle. On paper, that would have been a week of erasing.</p>
<p><strong>Look for the published method before writing your own.</strong> <em>(Added 21 September.)</em> The wind calculation was corrected three times before I asked what the literature said. The answer fitted in three lines and rested on measurements; the home-made model, on stacked assumptions. An AI writes a plausible physical model in thirty seconds, and that is precisely the danger: nothing pushes it to go and check whether one already exists.</p>
<p><strong>A field objection is worth a code review.</strong> I can&rsquo;t compute a lateral lift force. But &ldquo;I&rsquo;ve seen tall trailers that don&rsquo;t suffer from wind&rdquo;, &ldquo;a bike isn&rsquo;t a panel&rdquo;, &ldquo;there&rsquo;s a van in front&rdquo;: each of those remarks found a real flaw. Make them, and insist they be checked by calculation rather than answered with an argument.</p>
<p><strong>The lever was the architecture, not the calculation.</strong> <em>(Added on the evening of 21 September.)</em> A morning spent refining the wind model moved the result by a few km/h. Two design questions — why is the body so high, why are the bikes on the roof — took it from 55 to 91. The AI was diligently optimising the project I had given it; it never questioned it. That was my job, and it took me two days.</p>
<h2 id="whats-left-to-do">What&rsquo;s left to do</h2>
<ul>
<li><strong>Measure two dimensions on the Cordoba</strong>, car standing on its wheels: the height of the axle beam (it sets the 43 cm under the frame, hence everything else) and that of the coilover&rsquo;s lower eye (it sets the height of the wheel arch). While there, record the position of the arm pivots and the drilling pattern of their brackets.</li>
<li><strong>Design the frame for real</strong>: two rails inboard of the wheels, cross-members carrying the sides, a tube bridge over each wheel, and sealing of the wheel arches.</li>
<li><strong>Try the drawbar bike for real</strong>: it has to coexist with the jockey wheel and the gas locker door.</li>
<li><strong>Read the maximum rear axle load</strong> off the Cordoba&rsquo;s manufacturer plate, and check the bushes and bearings.</li>
<li><strong>Weigh it.</strong> The masses in the calculation are estimates. The finished trailer will go on a scale, nose and axle separately, and the axle can still slide a few centimetres along its rails before the final weld.</li>
<li><strong>Paperwork</strong> (French rules). Loaded, the trailer will exceed 500 kg gross weight: it will need its own registration, hence an individual type approval (RTI) from the DREAL for a home-built trailer. Staying under 750 kg means no mandatory brakes and a standard B licence. To be confirmed, along with the towing capacity and gross train weight on the T5&rsquo;s registration.</li>
<li><strong>Build light.</strong> Every kilo saved on the body is a kilo of margin on the axle and a bit more stability in a crosswind.</li>
</ul>
<h2 id="frequently-asked-questions">Frequently asked questions</h2>
<h3 id="can-you-use-a-mini-axle-for-a-trailer">Can you use a Mini axle for a trailer?</h3>
<p>Yes, for a small light trailer: it&rsquo;s compact, low, and the 10-inch wheels allow a low floor. But it&rsquo;s the rear axle of a 650 kg car, designed for something like 450 kg at most. A fitted-out trailer (kitchen, fridge, water, bikes) easily puts more than 500 kg on its axle.</p>
<h3 id="how-much-nose-weight-should-a-trailer-have">How much nose weight should a trailer have?</h3>
<p>Between 5 and 10% of the trailer&rsquo;s mass, never less than 4% and never more than the car&rsquo;s tow bar accepts (often 75 to 100 kg). Here, 7% fully loaded, about 44 kg. You get it by placing the axle, not by adding ballast.</p>
<h3 id="does-a-coil-spring-axle-take-up-more-room">Does a coil-spring axle take up more room?</h3>
<p>It depends how it&rsquo;s mounted. A separate spring sitting on the arm fits under the frame when the body sits above the wheels. A combined spring-and-damper unit, as on the Seat Cordoba 6K or VW Polo 6N, rises above the wheel: you have to make room for it in the body, raise the floor, or replace it with a separate spring and damper. Check before concluding, though: under load the spring compresses, and on my trailer the original unit ends up fitting within the depth of the frame, give or take one dimension.</p>
<h3 id="how-long-should-a-drawbar-be-for-easy-reversing">How long should a drawbar be for easy reversing?</h3>
<p>What matters is the ratio between the trailer&rsquo;s ball-to-axle distance and the car&rsquo;s rear-axle-to-ball distance. Above 2.5, reversing becomes comfortable; here, 2.8 with a 2 m drawbar. Shape matters as much as length: a single central beam near the ball keeps the drawbar&rsquo;s V from touching the car in tight turns.</p>
<h3 id="can-a-light-trailer-snake-behind-a-van">Can a light trailer snake behind a van?</h3>
<p>Very unlikely if the van is much heavier: the calculation finds no instability below 400 km/h with a 2.4 t T5 and a 0.6 t trailer. The practical risk is crosswind, and above all whatever you perch on the roof.</p>
<h3 id="at-what-wind-speed-can-a-trailer-blow-over">At what wind speed can a trailer blow over?</h3>
<p>It depends on its mass, its track, its height and the speed you drive at. The reference method is Baker and Soper (2018), used for traffic restrictions on bridges. For my 0.6 t trailer: an 85 km/h crosswind gust at 110 km/h with nothing on the roof, 55 km/h with three bikes. Those are wind speeds at trailer height; forecast gusts, measured 10 m up, are about 40% stronger.</p>
<h3 id="are-two-axles-better-than-one-on-a-small-trailer">Are two axles better than one on a small trailer?</h3>
<p>They carry more and hold their line better: the tyres resist yaw nearly five times as much. In exchange, the trailer scrubs its tyres when manoeuvring and can no longer be moved by hand, it weighs more unladen, and if the two axles are independent (rubber suspension), the tow-ball height sets how the load is shared: it has to be towed dead level.</p>
<h2 id="a-short-glossary">A short glossary</h2>
<p><strong>Bogie, tandem</strong> — Two closely spaced axles fitted one behind the other under the same trailer.</p>
<p><strong>Coilover (spring-and-damper unit)</strong> — A damper with the spring fitted around it; the two go on and come off as one piece. Its head bolts into the car&rsquo;s body.</p>
<p><strong>Nose weight</strong> — The vertical load the trailer puts on the tow ball. Too low or negative, it lightens the back of the car and encourages snaking.</p>
<p><strong>Snaking</strong> — Side-to-side oscillation of the trailer which, above a critical speed, grows instead of dying out.</p>
<p><strong>Tyre scrub</strong> — Forced sideways sliding of the tyres when a twin-axle trailer turns: its two axles cannot follow the same circle.</p>
<p><strong>Twist beam</strong> — An axle where the two trailing arms are joined by a cross-member that twists when the wheels don&rsquo;t move together; it acts as an anti-roll bar.</p>
<p><strong>Yaw</strong> — Rotation of a vehicle about a vertical axis: the nose goes left, the tail goes right.</p>
<p><strong>RTI</strong> — <em>Réception à titre isolé</em>: French individual type approval, by the regional authority (DREAL), of a vehicle built or modified as a one-off.</p>
<p><strong>Drawbar</strong> — The front part of the trailer, from the chassis to the coupling head.</p>
<p><strong>Track</strong> — The distance between the centres of the two wheels on one axle.</p>
<h2 id="references">References and local copies</h2>
<ul>
<li>The complete, regenerable model: Blender and Python scripts, <code>remorque.blend</code>, detailed calculation (comments and file names in French) — <a href="https://pf.olibrio.fr/assets/remorque-famille-cuisine-velos/remorque-famille.zip"><code>remorque-famille.zip</code></a></li>
<li>The calculation on its own, readable online (in French): <a href="https://pf.olibrio.fr/assets/remorque-famille-cuisine-velos/CALCULS.md"><code>CALCULS.md</code></a></li>
<li>The charts, as vector files (labels in French): <a href="https://pf.olibrio.fr/assets/remorque-famille-cuisine-velos/limites.svg"><code>limites.svg</code></a>, <a href="https://pf.olibrio.fr/assets/remorque-famille-cuisine-velos/architecture.svg"><code>architecture.svg</code></a>, <a href="https://pf.olibrio.fr/assets/remorque-famille-cuisine-velos/velos.svg"><code>velos.svg</code></a></li>
<li><a href="https://pure-oai.bham.ac.uk/ws/files/56018864/Baker_and_Soper_Calculation_of_the_overturning_wind_speed_of_large_road_vehicles_ICEproceedings.pdf">Baker &amp; Soper (2018), <em>The calculation of the overturning wind speed of large road vehicles at exposed sites</em></a> (open access) — the wind calculation method, and the measurement of a trailer behind its lorry</li>
<li><a href="https://mechanicalelements.com/torsion-axles-in-tandem-or-triple/">Mechanical Elements: rubber torsion axles fitted in pairs</a> — why they don&rsquo;t share the load</li>
<li><a href="https://club.autodoc.co.uk/manuals/how-to-change-rear-suspension-strut-on-vw-polo-6n1-replacement-guide-13252">Autodoc guide: replacing the rear suspension strut on a VW Polo 6N1</a> — the source for the spring correction</li>
<li><a href="https://pf.olibrio.fr/en/posts//en/posts/vibe-design-ventouse-tpu.html">The previous post</a> — another part designed in dialogue with an AI, and the trap of dimensions that add up</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>The bug I set out to fix was already fixed — and the fix was wrong</title>
      <link>https://pf.olibrio.fr/en/posts/premiere-contribution-libcamera.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/premiere-contribution-libcamera.html</guid>
      <pubDate>Tue, 08 Sep 2026 12:00:00 +0000</pubDate>
      <description>How do you pick what to contribute to when you&#x27;re starting out? I followed the usual advice — find a well-scoped task in an active project — and ran into three things nobody had reported: a wrong table entry in code merged two months ago and reviewed by four people, a missing sensor, and a performance defect the package maintainer had instrumented himself without anyone reporting it. The story of one evening on a Pixel 3a, with the dead ends, the prediction I got wrong, and what to write when you failed to prove something.</description>
      <category>contribution</category>
      <category>free software</category>
      <category>libcamera</category>
      <category>postmarketos</category>
      <category>pixel 3a</category>
      <category>method</category>
      <category>code review</category>
      <category>debugging</category>
      <category>first patch</category>
      <category>upstream</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/pixel3a/og-pixel3a.jpg" alt="The bug I set out to fix was already fixed — and the fix was wrong"></p>
<div class="tldr">
<p><strong>In two minutes</strong></p>
<ul>
<li><strong>Starting point</strong>: I wanted to contribute and didn&rsquo;t know where. I took the standard advice — a well-scoped task, in an active project, on hardware I own.</li>
<li><strong>First surprise</strong>: the task I picked had been done six weeks earlier. The warning I was seeing on my phone wasn&rsquo;t a gap, it was a version lag.</li>
<li><strong>Second surprise</strong>: the recent fix contained an error. Two values swapped, in code reviewed by four people including the maintainer, and marked as tested.</li>
<li><strong>The real work</strong>: a sensor missing from the tables. Values taken from the device, except one I couldn&rsquo;t prove — and flagged as such in the commit message.</li>
<li><strong>The failed prediction</strong>: I announced brighter photos after the fix. Measured: 39.2% before, 38.1% after. Nothing. The explanation is arithmetic, and it&rsquo;s instructive.</li>
<li><strong>The actual find</strong>: while chasing why the app kept freezing, a performance defect the package maintainer had instrumented himself, writing that it &ldquo;indicates actionable issues in the V4L2 or GPU drivers&rdquo;. Nobody had reported it.</li>
</ul>
<p><em>This post is about method rather than cameras. It should read even if <code>libcamera</code> means nothing to you. The technical side is in <a href="https://pf.olibrio.fr/en/posts/pixel-3a-postmarketos-linux-mainline.html">the previous post</a>.</em></p>
</div>
<h2 id="the-first-contribution-problem">The first-contribution problem</h2>
<p>Everyone gives the same advice: &ldquo;find an issue tagged <em>good first issue</em>&rdquo;. It&rsquo;s good advice and it doesn&rsquo;t work very well, for a simple reason — those tasks are either trivial enough to teach you nothing, or already taken by someone faster.</p>
<p>What works better, I think, is to start from what you have <strong>that others don&rsquo;t</strong>. In my case: an old phone I&rsquo;d decided to run Linux on, and therefore physical hardware on which to run code other people write blind.</p>
<p>That&rsquo;s a rarer advantage than it sounds. Plenty of developers work on drivers for hardware they don&rsquo;t own, relying on datasheets and user reports. Whoever has the device plugged in on their desk can answer questions nobody else can settle.</p>
<h2 id="the-thread-a-well-scoped-task">The thread: a well-scoped task</h2>
<p>The postmarketOS project keeps a per-device task list. For mine, an umbrella issue titled &ldquo;Camera TODOs&rdquo; listed a dozen items, all ticked except one:</p>
<blockquote>
<p>imx355 driver (front camera) is missing features for libcamera, makes the later complain (e.g. when running <code>cam -l</code>)</p>
</blockquote>
<p>Clear scope, reproducible symptom, one command to see it. Exactly what you look for.</p>
<p>I ran the command on the phone. It complained, as advertised. I opened the <code>libcamera</code> repository to write the fix.</p>
<p>And the entry was already there.</p>
<h2 id="first-lesson-an-open-issue-isnt-necessarily-open">First lesson: an open issue isn&rsquo;t necessarily open</h2>
<p>Support for that sensor had been added on 17 July 2026, by a Raspberry Pi engineer. The version installed on my phone dated from 10 July.</p>
<p><strong>Seven days apart.</strong> The warning I was seeing wasn&rsquo;t a gap in the project, it was a lag between the released version and the repository.</p>
<p>That&rsquo;s an easy confusion to make, and it deserves a reflex: before writing any code, check the problem still exists <strong>upstream</strong>, not just on your own machine. It takes two requests.</p>
<details>
<summary>Going deeper: comparing a release to the repository</summary>
<p>Most forges let you fetch a file at a given reference. Just compare the version you&rsquo;re running with the main branch:</p>
<pre><code class="language-bash">F=&quot;src/libcamera/sensor/camera_sensor_properties.cpp&quot;
for ref in v0.7.2 master; do
  echo -n &quot;$ref: &quot;
  curl -s &quot;.../repository/files/$(urlencode $F)/raw?ref=$ref&quot; | grep -c '&quot;imx355&quot;'
done
# v0.7.2: 0
# master: 1
</code></pre>
<p>Zero occurrences in the release, one in the repository: the work is done but not shipped. There&rsquo;s nothing to write.</p>
<p>The same reflex applies to the kernel. The second item on the list concerned a driver that didn&rsquo;t answer a particular query; there too the fix already existed upstream — absent from releases 6.18, 7.0 and 7.1, present in the main branch. It simply hadn&rsquo;t shipped yet.</p>
</details>
<p>Two tasks out of three had evaporated. I could have stopped there feeling I&rsquo;d wasted my evening. Except that reading the freshly added entry, something was off.</p>
<h2 id="second-lesson-review-doesnt-see-tables">Second lesson: review doesn&rsquo;t see tables</h2>
<p>The entry mapped two test patterns to numeric values. But the sensor&rsquo;s kernel driver defines those values in the opposite order. &ldquo;Solid colour&rdquo; and &ldquo;colour bars&rdquo; were <strong>swapped</strong>.</p>
<p>I checked three times, by independent routes:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Source</th>
<th>What it says</th>
</tr>
</thead>
<tbody>
<tr>
<td>The driver on my phone, queried directly</td>
<td><code>1 = solid colour</code>, <code>2 = colour bars</code></td>
</tr>
<tr>
<td>The driver&rsquo;s source in the official kernel</td>
<td>same order</td>
</tr>
<tr>
<td>Two other sensors with an identical menu, described right beside it</td>
<td>correct mapping</td>
</tr>
</tbody>
</table></div>
<p>And I actively looked for the context in which the author would have been right: his company maintains its own kernel, sometimes with different drivers. I checked — same order in both trees. The error was real, on his own hardware too.</p>
<p>What makes this interesting isn&rsquo;t the error. It&rsquo;s that the commit had been <strong>reviewed by four people</strong>, including the project maintainer, and carried a &ldquo;tested&rdquo; tag from a fifth.</p>
<p>How do five competent people miss two swapped values?</p>
<p>Because there&rsquo;s <strong>no logic to review</strong>. It&rsquo;s a correspondence between two documents that don&rsquo;t live in the same repository: a table on one side, an array of strings in the kernel on the other. To check it you must open both files side by side, or have the device to hand. And &ldquo;tested&rdquo; here meant <em>the camera works, I point it at a scene and get an image</em> — which is true, and never exercises test patterns, which are a diagnostic tool.</p>
<p>The rest of the entry was right. Pixel size, sensor delays: everything exercised day to day was correct. Only the part nobody runs was wrong.</p>
<p><strong>That&rsquo;s where owning the device becomes a decisive advantage.</strong> Not because I&rsquo;m better, but because I was the only one who could put the question to the hardware.</p>
<h2 id="what-a-two-line-patch-has-to-contain">What a two-line patch has to contain</h2>
<p>The patch is two lines. Its commit message is thirty, deliberately.</p>
<p>A reviewer should have nothing to go and look up. So I put in the kernel driver&rsquo;s table quoted verbatim, the output of the command querying my phone, the concrete consequence in one sentence — <em>asking for a solid colour produces colour bars, and vice versa</em> — and the consistency argument with the two correctly described sensors.</p>
<p>And I pre-empted the question that was coming: &ldquo;are the other entries wrong too?&rdquo; No, and I checked driver by driver: two other sensors do use the reverse order, because <strong>their</strong> drivers define it that way. One sentence in the message saves a three-day round trip.</p>
<details>
<summary>Going deeper: the commit-id trap</summary>
<p>These projects use a convention to reference the commit you&rsquo;re fixing:</p>
<pre><code>Fixes: a1db25dabaee (&quot;libcamera: camera_sensor: Add Sony IMX355 sensor properties&quot;)
</code></pre>
<p>The identifier is twelve characters. I had seven in front of me and <strong>filled in the missing five from memory</strong>. They were wrong. A made-up identifier that looks real is worse than a missing one: nobody checks it, and it points at nothing.</p>
<p>The correct form asks the tool rather than your memory:</p>
<pre><code class="language-bash">git rev-parse --short=12 &lt;reference&gt;
</code></pre>
<p>It&rsquo;s a small thing. It&rsquo;s also exactly the kind of detail that gets a first patch sent back.</p>
</details>
<h2 id="the-real-work-and-the-value-you-cant-prove">The real work, and the value you can&rsquo;t prove</h2>
<p>Something genuinely missing remained: the phone&rsquo;s other sensor appeared nowhere. Not in the library, not in the official kernel — its driver was written at a chip maker eight years ago and never sent upstream.</p>
<p>I took the values from the device and from the driver&rsquo;s source. Three follow rigorously. The fourth doesn&rsquo;t: it&rsquo;s a parameter you read in a datasheet, and datasheets for these sensors aren&rsquo;t public.</p>
<p>I tried to back it up. I found a configuration file, on my own phone, declaring exactly the value I&rsquo;d assumed. Excellent news for about three minutes — until I checked whether it was <strong>independent</strong>. It wasn&rsquo;t: all ten equivalent files in the project declare the same value, including for sensors from three different manufacturers. That wasn&rsquo;t ten concurring measurements, it was one default copied ten times.</p>
<p><strong>So I wrote it as it stands in the commit message</strong>: this value follows what&rsquo;s documented for sensors in the same family. Not &ldquo;per the datasheet&rdquo;, no phrasing that would suggest a measurement. A reviewer with the documentation will correct it in one message, and that&rsquo;s fine.</p>
<p>Dressing up an uncertainty is the most efficient way to lose a project&rsquo;s trust on your very first patch.</p>
<h2 id="the-prediction-i-got-wrong">The prediction I got wrong</h2>
<p>Reading the code, I&rsquo;d worked out what the sensor&rsquo;s absence caused: without it, the library no longer converts the camera&rsquo;s gain. It writes a setting number where it should write an amplification factor, and reads the number back as if it were the factor. The auto-exposure loop is wrong in both directions.</p>
<p>From that I made a prediction: after the fix, low-light photos should be better. I set up a clean protocol — phone propped and never moved, objective luminance and noise measurements, a before series and an after series.</p>
<p>Result: <strong>39.2% luminance before, 38.1% after.</strong> Nothing.</p>
<p>The explanation is arithmetic, and I had flagged it as a risk before running the test — without drawing the consequence, which was the mistake.</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Gain requested</th>
<th>Correct formula</th>
<th>Broken formula</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>1.0×</td>
<td>≈ 1.0×</td>
</tr>
<tr>
<td>50</td>
<td>1.1×</td>
<td>50×</td>
</tr>
<tr>
<td>300</td>
<td>2.4×</td>
<td>300×</td>
</tr>
</tbody>
</table></div>
<p>The error is only huge at high gain. My test scene had a saturated white area: auto-exposure had light to spare, stayed near zero gain, and at that point <strong>both formulas give the same result</strong>.</p>
<p>The patch is still correct, and it works — I verified that differently. After installation, the library emits no warning at all for that sensor, while the phone&rsquo;s other sensor, unpatched, still emits every one of them. A clean negative control.</p>
<p><strong>But I didn&rsquo;t demonstrate a visible improvement, so I won&rsquo;t write that I did.</strong> The &ldquo;tested&rdquo; tag I&rsquo;ll attach will say the sensor is now recognised. Not that the images are better.</p>
<p>It&rsquo;s a distinction that sounds pedantic and isn&rsquo;t. A correct piece of reasoning about a mechanism says nothing about its magnitude under test conditions. I had the mechanism; I didn&rsquo;t have the magnitude.</p>
<h2 id="the-find-that-was-on-no-list">The find that was on no list</h2>
<p>Throughout all this, the camera app kept freezing. A nuisance, which I worked around by relaunching.</p>
<p>Then I instrumented it, for lack of anything better. And it turned out that a <strong>single line</strong>, emitted once at initialisation, explained everything:</p>
<pre><code>Importing input DMABuf failed, falling back to upload
</code></pre>
<p>Image processing runs on the graphics processor. Normally the raw frame reaches it without a copy, through shared memory. Here the import fails — and the library switches to copy mode: <strong>twelve megapixels transferred to the GPU on every frame</strong>. The memory bus saturates, the display can no longer get bandwidth for its own operations, and the pipeline ends up strangled.</p>
<p>It isn&rsquo;t a crash. It&rsquo;s a suffocation, which explains why it left no usable trace.</p>
<p>And here&rsquo;s what gives that line its value. It comes from a patch added by the package maintainer, who had <strong>deliberately restored it</strong> after the upstream project removed it. His rationale:</p>
<blockquote>
<p>failing imports majorly impact performance and indicate actionable issues in the V4L2 or GPU drivers.</p>
</blockquote>
<p>So the maintainer had planted the detector, stating in writing that its firing warrants investigation. It fired on my phone. Nobody had reported it.</p>
<p>I haven&rsquo;t proved causation yet — the correlation is clear, the mechanism coherent, and a simple test would settle it. But it is by far the most useful thing I found that morning, and it was on no task list.</p>
<h2 id="what-i-take-away-for-next-time">What I take away, for next time</h2>
<p><strong>What you bring isn&rsquo;t talent, it&rsquo;s a position.</strong> I wasn&rsquo;t better than anyone. I had the device plugged in and five competent reviewers didn&rsquo;t. That&rsquo;s all, and it&rsquo;s enough.</p>
<p><strong>Check the problem still exists before writing.</strong> Two tasks out of three had evaporated, fixed upstream but not yet released. Two requests would have said so immediately.</p>
<p><strong>A confirming source is only useful if it&rsquo;s independent.</strong> Ten documents copying the same default are worth no more than one.</p>
<p><strong>Separate what&rsquo;s demonstrated from what&rsquo;s inferred</strong> — in a commit message as in conversation. I had a correct mechanism and a wrong prediction; stating one without the other would have been a polite lie.</p>
<p><strong>And instrument what annoys you.</strong> The app freezing was a nuisance I&rsquo;d been working around for hours. It was when I stopped working around it that I found the one thing nobody else was looking for.</p>
<p>It really is a ball of string: you pull on a two-line thread, and three things you never asked for come out with it.</p>]]></content:encoded>
    </item>
    <item>
      <title>Vibe design: getting an AI to model a part it will never see</title>
      <link>https://pf.olibrio.fr/en/posts/vibe-design-ventouse-tpu.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/vibe-design-ventouse-tpu.html</guid>
      <pubDate>Tue, 08 Sep 2026 12:00:00 +0000</pubDate>
      <description>How to model a TPU suction cup from a paper sketch, in dialogue with an AI that has neither the part nor the calipers. Why open questions go in circles, why a wrong render moves faster than a good question, why dimensions that add up prove nothing, and what 95A TPU forces onto the drawing when you print through a Bowden tube.</description>
      <category>3d printing</category>
      <category>tpu</category>
      <category>cad</category>
      <category>python</category>
      <category>manifold3d</category>
      <category>ai</category>
      <category>vibe coding</category>
      <category>flsun qq-s</category>
      <category>suction cup</category>
      <category>flexible filament</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/vibe-design-ventouse-tpu/og.jpg" alt="Vibe design: getting an AI to model a part it will never see"></p>
<div class="tldr">
<p><strong>In two minutes, if you want an AI to model a part without opening a CAD program</strong></p>
<ul>
<li><strong>The setup</strong>: a rubber suction cup to replace, a pair of calipers, a sheet of paper — and an AI I describe the part to. It cannot see it, touch it or measure anything. Every dimension comes from me.</li>
<li><strong>What doesn&rsquo;t work</strong>: asking questions. I sent a correct dimensioned sketch, the AI listed six ambiguities, I answered them one by one — and six exchanges later it still had the geometry wrong.</li>
<li><strong>What works</strong>: <strong>making it show</strong>. A wrong render, displayed with its assumptions colour-coded, gets corrected in one sentence: &ldquo;the 6.5 is the shaft, not the overall height&rdquo;. Two iterations settled what six open questions could not.</li>
<li><strong>The trap</strong>: my dimensions closed perfectly — 1 + 3.5 + 2 = 6.5 — on a part that was entirely wrong. <strong>A sum that adds up is not proof of a correct reading.</strong> See <a href="#sum-adds-up">The trap of the sum that adds up</a>.</li>
<li><strong>When a word is the blocker</strong>: I said &ldquo;hollow&rdquo;, my counterpart heard &ldquo;the hole&rdquo;. No amount of rephrasing helped; a diagram that <strong>colours in the volume being discussed</strong> settled it in one send.</li>
<li><strong>The material side</strong>: drawing a suction cup in 95A TPU is not drawing a suction cup. Real rubber is 40–60 Shore A, printing leaves micro-channels between layers, and on a printer with a remote extruder, flexible filament is the worst case. All of that changes the drawing, not just the settings.</li>
<li><strong>The code</strong>: the part is a 120-line Python script (<a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/ventouse.py"><code>ventouse.py</code></a>), every dimension a constant at the top. Changing a value and regenerating the STL takes ten seconds — which is what makes the back-and-forth bearable.</li>
</ul>
<p><em>No CAD knowledge assumed. This follows <a href="https://pf.olibrio.fr/en/posts//en/posts/flsun-qqs-delta-klipper-calibration.html">the calibration of the same printer</a>, but stands on its own.</em></p>
</div>
<p><img alt="Freehand sketch of a suction cup dimensioned in blue pen, top view, section A-A and bottom view, with the red rubber cup lying beside the sheet" src="https://pf.olibrio.fr/images/vibe-design-ventouse-tpu/00-croquis.jpg" /></p>
<p><em>The starting point: three views, seven dimensions, and the original part next to them. This photo is everything the AI will ever have of this suction cup.</em></p>
<h2 id="the-problem-isnt-drawing-its-understanding-each-other">The problem isn&rsquo;t drawing, it&rsquo;s understanding each other</h2>
<p>I had a rubber suction cup to remake and a freshly calibrated printer. The modelling itself isn&rsquo;t the point: a suction cup is a disc, a cylinder, a stud and a bore. Any CAD program does it in ten minutes — provided you know <strong>what you&rsquo;re drawing</strong>.</p>
<p>The point is that I wanted an AI to draw it. Not out of convenience: because I wanted to see where it breaks. An AI writing code has the shortest possible feedback loop — the code runs or it doesn&rsquo;t. An AI drawing a mechanical part has <strong>no</strong> feedback loop: it cannot see the object, cannot measure anything, and its result only becomes wrong at the moment the part comes off the printer and won&rsquo;t go into its socket. Everything in between is conversation.</p>
<p>So I made a sketch. Three views, dimensioned, tidy: what you&rsquo;re taught in technical drawing. And that&rsquo;s where it gets interesting, because that sketch is <strong>correct</strong> — and it wasn&rsquo;t enough.</p>
<h2 id="six-questions-asked-on-my-own-photo">Six questions asked on my own photo</h2>
<p>The AI&rsquo;s first move, and it was the right one: don&rsquo;t guess. It took my photo, dropped six numbered red markers on it, and sent the image back with its six questions attached to precise spots on the drawing.</p>
<p><img alt="The same sketch with six numbered red markers added by the AI, each connected by a line to an ambiguous dimension or detail of the drawing" src="https://pf.olibrio.fr/images/vibe-design-ventouse-tpu/01-questions.jpg" /></p>
<p><em>The six ambiguities, marked on the sketch itself. Annotating the image rather than describing in words: the only thing in this whole story that worked first time.</em></p>
<p>I already knew that method: it comes from the previous post, where describing a measuring gesture in prose blocked me twice in a row, and where annotating my own photo unblocked it in a single send. It works both ways. When the AI doesn&rsquo;t understand my drawing, it has to show me <strong>where</strong> it doesn&rsquo;t understand, not ask me to &ldquo;please clarify the dimensions&rdquo;.</p>
<p>And yet. I answered all six, one line each. &ldquo;It&rsquo;s a section view of the same part, it shows the thickness of the cup.&rdquo; &ldquo;That&rsquo;s the hole, underneath the cup.&rdquo; &ldquo;3 mm across and 4 mm deep.&rdquo; &ldquo;It&rsquo;s blind.&rdquo; Six exact, unambiguous answers. After which the geometry was <strong>still wrong</strong>.</p>
<h2 id="what-a-sketch-lacks-isnt-dimensions">What a sketch lacks isn&rsquo;t dimensions</h2>
<p>Here&rsquo;s the knot. My sketch carried every dimension needed. What it didn&rsquo;t carry is <strong>what each dimension attaches to</strong> — and that is precisely what a freehand drawing fails to convey, whereas a proper dimensioned drawing conveys it through the position of the extension lines.</p>
<p>An example. I had written &ldquo;6.5 mm&rdquo; with a vertical arrow to the right of my section. Obvious to me: the height of the shaft. Just as obvious to the AI: the overall height, since the arrow runs from one end of the drawing to the other. Both readings hold up on a photo taken at an angle, where the arrow overshoots slightly.</p>
<p>Same with the hole and the stud, both roughly 3-and-a-bit millimetres, at opposite ends of the part. I wrote &ldquo;3&rdquo; on one and &ldquo;3.5&rdquo; on the other knowing which was which. Nothing on the paper said so.</p>
<h2 id="show">What works: make it show, not say</h2>
<p>After the sixth exchange, a change of method: instead of asking a seventh question, the AI modelled the part <strong>with its assumptions</strong>, rendered it, and sent it over — with an explicit colour code on the dimensioned section: <strong>red for the dimensions that came from me, cyan for the ones it had invented</strong>.</p>
<p>I looked at the render for three seconds and wrote: &ldquo;from the top of the stud to the edge of the lip, the cup is 10.5 mm. 2 mm of stud, 6.5 mm of shaft, 2 mm of cup body.&rdquo;</p>
<p><img alt="Two suction cup sections side by side: on the left the wrong, squashed version at 6.5 overall; on the right the correct one, 6.5 shaft and 10.5 overall" src="https://pf.olibrio.fr/images/vibe-design-ventouse-tpu/02-deux-lectures.png" /></p>
<p><em>Same part, same dimensions, two readings. Left, what the AI had understood; right, what I had drawn. No question would have produced that sentence — the wrong drawing produced it.</em></p>
<p>That&rsquo;s the interesting reversal. An open question (&ldquo;what does the 6.5 measure?&rdquo;) asks me to rebuild the context mentally, recover what I had in mind three days earlier, and formulate an answer into the void. A wrong render puts the geometry in front of me, and <strong>my eye corrects faster than my memory</strong>. The correction costs nothing any more: I can see the shaft is squashed, so I know what to say.</p>
<p>There&rsquo;s a condition for this to work, and it isn&rsquo;t trivial: <strong>the AI has to flag what it invents</strong>. A clean render, with no distinction between what came from me and what it filled in, would have had me approving a part containing five values pulled out of thin air. The cyan on the section is the admission of ignorance made visible. It is what turns the render into a measuring instrument rather than an illustration.</p>
<h2 id="sum-adds-up">The trap of the sum that adds up</h2>
<p>This is the part I find most instructive, and it&rsquo;s worth pausing on.</p>
<p>When the AI produced its second version — the one with the shaft, still wrong — it explained that it had finally managed to place the last orphan dimension from my sketch, the infamous 3.5. Its demonstration: the cup body is 1.0, the shaft 3.5, the stud 2.0, <strong>total 6.5</strong>, exactly the overall height on the drawing. Every dimension closed on the others. It wrote, and it was sincere: &ldquo;your five dimensions close on each other, which is a good sign for my reading.&rdquo;</p>
<p>Except it was wrong. The 6.5 wasn&rsquo;t the overall height, the 3.5 wasn&rsquo;t the shaft height, and the reconstructed part looked like nothing on earth. The correct reading gives 2 + 6.5 + 2 = 10.5, and the 3.5 is the bore diameter — a dimension with nothing to do with a stack of heights.</p>
<p><strong>A sum that adds up is not proof.</strong> With seven dimensions and a part of revolution, there are enough degrees of freedom for wrong combinations to close by chance. Arithmetic consistency is weak evidence, and it&rsquo;s exactly the kind of weak evidence an AI presents with confidence because it <em>looks like</em> a verification. It is reasoning with the shape of a proof and none of its force.</p>
<p>What settled it wasn&rsquo;t a calculation: it was that the drawn part didn&rsquo;t look like the part on the table. The only judge here is the object.</p>
<h2 id="when-a-word-is-the-blocker-colour-it-in">When a word is the blocker, colour it in</h2>
<p>One last blocker, of a different kind. For a suction cup to hold, its underside must not be flat: it rises slightly towards the centre. For want of a better word I call that the &ldquo;hollow&rdquo;. You press, the trapped air escapes past the lip; you let go, the rubber tries to resume its shape, pressure drops in the cavity, and atmospheric pressure holds the part down. That&rsquo;s the entire mechanism.</p>
<p>Except my part also has a blind hole in the middle. So when someone said &ldquo;hollow&rdquo;, I answered: &ldquo;what do you call the hollow? in the middle of the cup there&rsquo;s a hole 3.5 mm across and 4 mm deep, is that the recess at the centre?&rdquo;</p>
<p>Rephrasing would have got nowhere — the word was already taken. What worked fits in one image:</p>
<p><img alt="Section of the suction cup resting on a hatched surface, with the trapped air volume under the cup in blue and the bore rising into the shaft in red" src="https://pf.olibrio.fr/images/vibe-design-ventouse-tpu/03-creux.png" /></p>
<p><em>The hollow is the blue. The hole is the white one, at the opposite end. Colouring in the volume you&rsquo;re talking about settles in one image what three paragraphs would not.</em></p>
<p>The general lesson: when an exchange stalls on a word, it isn&rsquo;t a vocabulary problem but a referent problem. Don&rsquo;t define the word better — <strong>point at the thing</strong>. In geometry, pointing is done with colour.</p>
<p>One detail that matters: on its first sections, the AI drew the bore as a white rectangle overshooting below the part and into the hollow, &ldquo;so you can see it clearly&rdquo;. I told it so — that view was less clear, because an overshooting outline suggests geometry that doesn&rsquo;t exist, and it overshot precisely into the area we were trying to discuss. A technical diagram doesn&rsquo;t improve by exaggerating lines; it improves by framing the relevant area and colouring the volumes.</p>
<h2 id="the-part">The part</h2>
<p><img alt="Three views of the modelled suction cup: three-quarter top view showing shaft and stud, bottom view showing the cup and the bore, and a dimensioned section" src="https://pf.olibrio.fr/images/vibe-design-ventouse-tpu/04-final.png" /></p>
<p><em>The finished part: Ø19.5 cup, 2 body, Ø9 × 6.5 shaft, Ø3 × 2 centring stud, Ø3.5 × 4 blind bore. 10.5 overall.</em></p>
<p>The model is a 120-line Python script using <a href="https://github.com/elalish/manifold">manifold3d</a>, a solid geometry library. The part being a body of revolution, everything fits in a <code>(radius, height)</code> profile that gets swept:</p>
<pre><code class="language-python">def profil(n=80):
    &quot;&quot;&quot;Closed (r, z) contour of the half-section, counter-clockwise.&quot;&quot;&quot;
    p = []
    r = np.linspace(0, R_INT, n)                    # underside: parabolic concavity
    p += [(x, CREUX * (1 - (x / R_INT) ** 2)) for x in r]
    p += [(R_EXT, 0.0), (R_EXT, E_LEVRE)]           # lip: flat seat then flank
    r = np.linspace(R_EXT, R_FUT, n)                # top of the body, slightly domed
    t = (r - R_FUT) / (R_EXT - R_FUT)
    p += [(x, E_LEVRE + (Z_FUT - E_LEVRE) * (1 - u) ** BOMBE) for x, u in zip(r, t)]
    p += [(R_FUT, Z_TET), (R_TETON, Z_TET), (R_TETON, H), (0.0, H)]
    return p

m = Manifold.revolve(CrossSection([profil()]), 128) - trou
</code></pre>
<p>What makes the exercise workable isn&rsquo;t the library: it&rsquo;s that <strong>every dimension is a constant at the top of the file</strong>, each commented with where it came from — sketch, deduction, or assumption. Changing a value and regenerating the STL takes ten seconds. Over five round trips, that&rsquo;s the difference between a conversation and a chore.</p>
<details>
<summary>Going deeper: why a revolved profile rather than stacked cylinders</summary>
<p>You could draw this part by stacking primitives: a cylinder for the body, one for the shaft, one for the stud, a subtracted cylinder for the bore. It works, and it&rsquo;s what an AI naturally does when asked for &ldquo;a suction cup&rdquo;.</p>
<p>The problem is the cup itself. Its underside is a curved surface, and its thickness varies from rim to centre — which is what lets it deform under thumb pressure and spring back. With primitives you&rsquo;d subtract a sphere segment or an ellipsoid, and the resulting thickness would become a consequence that&rsquo;s hard to control.</p>
<p>With a revolved profile you describe both surfaces directly — the underside as a parabola <code>z = hollow · (1 − (r/R)²)</code>, the top as a domed curve with a tunable exponent — and thickness is the difference between them, visible and controllable at every point. On a flexible part whose entire function lies in how it deforms, that&rsquo;s the only parameterisation that gives you a handle.</p>
<p><code>Manifold.revolve</code> takes a cross-section in the XY plane, spins it about the Y axis, and returns a solid whose axis is Z. The blind bore is then subtracted as a cylinder — drawn, in the preview section, <strong>exactly</strong> between its entry and its floor, with no overshoot.</p>
</details>
<h2 id="drawing-for-tpu-isnt-drawing-a-suction-cup">Drawing for TPU isn&rsquo;t drawing a suction cup</h2>
<p><img alt="Orange Overture TPU spool, label showing nozzle 210-230 °C, bed 25-60 °C, print speed 20-40 mm/s and a batch number" src="https://pf.olibrio.fr/images/vibe-design-ventouse-tpu/05-bobine-tpu.jpg" /></p>
<p><em>The spool that started all this. The label says 20 to 40 mm/s — while noting, a little higher up, that it targets direct-drive printers.</em></p>
<p>A real suction cup is silicone or soft PVC, around <strong>40 to 60 Shore A</strong>. The most common TPU, the one I have, is <strong>95A</strong>: roughly the hardness of a trolley wheel. That isn&rsquo;t a settings detail, it&rsquo;s a design constraint.</p>
<p>A thick 95A lip won&rsquo;t conform to the micro-defects of the surface: it will sit on three high points and leak between them. So the lip has to be <strong>thin</strong>, 0.6 to 0.8 mm, thin enough to flex — at the cost of fragility. And it gets worse: a fused-filament part is a stack of welded beads, and between two beads there remain <strong>micro-channels</strong>. On a cosmetic part nobody notices. On a part whose job is to hold a vacuum, they&rsquo;re leaks. Hence thin layers, a high temperature and a slight excess of material — not for strength, but to <strong>weld the layers to each other</strong>.</p>
<p>That&rsquo;s the kind of trade-off a dialogue with an AI surfaces well, provided you give it the material <strong>before</strong> the geometry. Had I asked for &ldquo;a suction cup&rdquo; without saying more, I&rsquo;d have got a 2 mm lip: correct in moulded rubber, useless here.</p>
<h2 id="tpu-on-a-remote-extruder-printer-the-worst-case">TPU on a remote-extruder printer: the worst case</h2>
<p>Then it has to be printed, and that&rsquo;s where my machine speaks up. On a <strong>FLSun QQ-S</strong>, the motor pushing the filament isn&rsquo;t on the head: it&rsquo;s on the frame, and the filament travels about 700 mm through a guide tube before reaching the nozzle. That&rsquo;s a <strong>Bowden</strong> setup. With rigid filament it works: the filament behaves like a rod being pushed. With flexible filament it behaves like a spring — you push at one end, nothing comes out the other, then it all comes out at once.</p>
<p>Three consequences, and they show up in the slicing profile:</p>
<ul>
<li><strong>Retraction.</strong> My PLA profile retracts 5 mm. On TPU, 5 mm slackens the filament enough that it can coil at the tube entry and jam — that&rsquo;s <em>the</em> failure mode of flexibles in Bowden. Down to 2 mm, at reduced speed.</li>
<li><strong>Speed.</strong> The label says 20 to 40 mm/s, assuming a direct extruder. In Bowden, 15 to 25.</li>
<li><strong>Acceleration.</strong> On a fast delta, jolts get absorbed by the elasticity of the filament rather than by the nozzle: material goes missing in the corners. Brought down from 1500 to 800 mm/s², restored at end of print.</li>
</ul>
<details>
<summary>Going deeper: should the extruder go on the head?</summary>
<p>It&rsquo;s the question that comes up immediately: if the tube is the problem, why not mount the extruder on the head and do away with it? I happen to have a dual-drive extruder waiting, an OMG V2-S, weighing <strong>64 g</strong> and mountable either as Bowden or direct.</p>
<p>The arithmetic is less favourable than it looks, because <strong>the weight isn&rsquo;t in the extruder, it&rsquo;s in the motor</strong>: about 215 g with a pancake motor, 365 g with a standard one. And on a delta, the effector moves in all three axes, unlike a Cartesian where a direct extruder only loads the X axis.</p>
<p>Now, a structure&rsquo;s resonant frequency follows <code>f ∝ 1/√m</code>. My machine already resonates at 20 Hz, which is low; doubling the moving mass would drop it towards 14 Hz. Klipper&rsquo;s documentation holds that below 25 Hz you should <strong>stiffen the machine rather than compensate</strong> — below that, the input shaper starts rounding off detail. I&rsquo;d be paying for TPU comfort with a degradation on everything else, on a machine I&rsquo;ve only just calibrated.</p>
<p>The practical conclusion: keep the Bowden, and attack the problem from the other end. The real culprit for flexibles isn&rsquo;t tube length, it&rsquo;s the stock <strong>single-drive</strong> extruder, under which soft filament escapes sideways. A dual drive grips it on both sides. And a 1.9 mm bore tube instead of 2.0 stops the filament buckling in the clearance. Two cheap parts that fix most of it without touching the machine&rsquo;s dynamics.</p>
</details>
<h2 id="what-i-take-from-it">What I take from it</h2>
<p><strong>An AI that draws has no feedback loop.</strong> It can&rsquo;t see the part, can&rsquo;t measure anything, and its mistake only becomes visible at print time. The whole job is to build that feedback loop by hand — and the render is it.</p>
<p><strong>Showing beats asking.</strong> An open question asks me to rebuild context; a wrong drawing puts the geometry in front of me and my eye corrects on its own. Six questions didn&rsquo;t converge; two renders did.</p>
<p><strong>The AI must flag what it invents.</strong> The red/cyan code — your dimensions, my assumptions — is what turns a pretty render into a measuring instrument. Without it I&rsquo;d have approved five values from nowhere.</p>
<p><strong>Arithmetic consistency is not verification.</strong> My dimensions closed perfectly on a wrong part. An AI presents that kind of evidence with the confidence of a proof, because it has the shape of one. The only judge is the object on the table.</p>
<p><strong>When a word blocks, point at the thing.</strong> No rephrasing: an image where you colour in the volume you mean.</p>
<p><strong>Material comes before geometry.</strong> &ldquo;A suction cup&rdquo; and &ldquo;a suction cup in 95A TPU printed by fused deposition&rdquo; are not the same part, and you don&rsquo;t get the second by tweaking the settings of the first.</p>
<p><strong>And parameterisation makes it all bearable.</strong> Five successive versions, ten seconds each. Had every correction meant reworking a CAD model, I&rsquo;d have given up at the second render and drawn the part myself — which, frankly, would have been perfectly reasonable.</p>
<h2 id="where-things-stand">Where things stand</h2>
<div class="status">
<p><strong>As of 8 September 2026: model frozen, printing pending.</strong> The geometry is validated on the dimensioned section, the script outputs the STL and its preview, the TPU slicing profile is written. Printing waits on fitting the dual-drive extruder, then a moisture test on the filament — the spool dates from November 2023, and TPU takes up water faster even than PETG. One unknown will only be settled by trying: the real diameter of the printed shaft. Drawn at Ø9, it will likely come out at Ø9.2; in TPU that&rsquo;s recoverable, the part compresses as it goes in. In PLA it would have taken three attempts.</p>
</div>
<h2 id="to-reproduce">To reproduce</h2>
<p><strong>What you need</strong>: the original part, calipers, something to sketch on, and a Python environment with <code>manifold3d</code>, <code>numpy</code> and <code>matplotlib</code>. No CAD program.</p>
<p><strong>The sketch.</strong> Three views beat one perspective. But above all: <strong>write next to each dimension what it measures</strong>, in words. &ldquo;6.5 = shaft height&rdquo;, not &ldquo;6.5&rdquo;. It&rsquo;s ugly on a proper drawing; it&rsquo;s what saves you five round trips here.</p>
<p><strong>The loop.</strong> At every iteration, demand three outputs: the STL, a <strong>3D view</strong> (does it look like the object?) and a <strong>dimensioned section</strong> (are the dimensions in the right places?). The section finds the errors; the 3D view finds the absurdities.</p>
<p><strong>The colour code.</strong> Explicitly ask that dimensions taken from your sketch and those invented by the AI be distinguished on screen. It&rsquo;s the highest-yield point in the whole method.</p>
<p><strong>The drawing rules</strong> that emerged, for a readable technical diagram: every shape stops at its real geometry, never an outline overshooting &ldquo;so you can see it&rdquo;; text outside the part, never on top of it; frame on the area under discussion — a 1 mm hollow is invisible at the scale of a 20 mm part; and colour volumes rather than thickening lines.</p>
<p><strong>The files</strong>: the model <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/ventouse.py"><code>ventouse.py</code></a> and the <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/ventouse.stl">STL</a> it produces; the slicing profile <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/qqs-tpu.ini"><code>qqs-tpu.ini</code></a> (PrusaSlicer on the command line, 260 mm delta, 0.4 nozzle); and <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/esp32-supermini-tray.py"><code>esp32-supermini-tray.py</code></a>, an earlier part made with the same toolchain, to compare a prismatic geometry with a revolved one.</p>
<p><strong>The TPU profile in short</strong>, if you print flexibles in Bowden: 2 mm retraction at 25 mm/s, perimeters 15–20 mm/s, acceleration 800, nozzle 225–230, bed 45–50, fan 40–60 %, a first layer less squashed than usual (flexibles push back instead of spreading), and a glue stick on the glass as a <strong>release agent</strong> — TPU sticks well enough to pull out a chip.</p>
<h2 id="frequently-asked-questions">Frequently asked questions</h2>
<h3 id="can-you-really-do-cad-by-talking-to-an-ai-with-no-software">Can you really do CAD by talking to an AI, with no software?</h3>
<p>For a simple, well-defined part, yes, and the result is parametric, versionable and commented — three things a CAD file does badly. For a part whose shape you don&rsquo;t yet know, no: the dialogue costs more than the sketch. The tipping point is roughly where you can describe the part in one sentence.</p>
<h3 id="why-wasnt-my-dimensioned-sketch-enough">Why wasn&rsquo;t my dimensioned sketch enough?</h3>
<p>Because a freehand sketch carries the dimensions but not their attachments. On a proper drawing, the position of the extension lines says what a dimension refers to; freehand, on a photo taken at an angle, that information disappears. Writing out what each dimension measures restores it.</p>
<h3 id="can-an-ai-invent-dimensions-without-saying-so">Can an AI invent dimensions without saying so?</h3>
<p>It necessarily does — a part has more dimensions than a sketch carries. The question is whether it flags them. Insist that invented values be marked in the render and commented in the code; it&rsquo;s checkable at a glance, and it changes everything.</p>
<h3 id="is-95a-tpu-suitable-for-a-suction-cup">Is 95A TPU suitable for a suction cup?</h3>
<p>It&rsquo;s considerably harder than the silicone of a real cup (40–60 Shore A). It can work on a very smooth surface with a thin lip and thin layers, but don&rsquo;t expect the grip of a moulded part. A softer TPU, 85A, would suit the job better — and be far harder to print in Bowden.</p>
<h3 id="do-you-need-a-direct-extruder-for-tpu">Do you need a direct extruder for TPU?</h3>
<p>Not for 95A; in practice yes for 85A and softer. In Bowden, what matters most isn&rsquo;t tube length but the extruder: a dual drive grips the filament on both sides, where a single wheel lets it escape. A 1.9 mm bore tube completes it nicely.</p>
<h3 id="why-not-mount-the-extruder-on-a-deltas-head">Why not mount the extruder on a delta&rsquo;s head?</h3>
<p>Because moving mass is the critical parameter there, and it penalises all three axes at once. Doubling the effector&rsquo;s mass drops the resonant frequency by a factor of √2; below 25 Hz, software compensation starts rounding off detail. See the panel in the Bowden section.</p>
<h3 id="how-long-did-the-whole-thing-take">How long did the whole thing take?</h3>
<p>About an hour of conversation, five versions of the model. Most of that time went into clearing six sketch ambiguities — not into drawing.</p>
<h2 id="small-glossary">Small glossary</h2>
<p><strong>Bowden (setup)</strong> — The motor pushing the filament is fixed to the frame, and the filament reaches the nozzle through a guide tube. Lightens the moving head, but introduces elasticity into the push.</p>
<p><strong>Direct drive</strong> — The opposite: the motor sits on the head. Better control of the material, heavier head.</p>
<p><strong>Effector</strong> — On a delta printer, the moving part carrying the nozzle, suspended from the three pairs of arms.</p>
<p><strong>manifold3d</strong> — A solid geometry library guaranteeing always-valid meshes; you describe the part in Python through boolean operations and revolutions.</p>
<p><strong>Pressure advance</strong> — Software compensation for the lag between the extrusion command and material actually coming out, caused by filament compressibility. Higher the softer the setup.</p>
<p><strong>Retraction</strong> — Pulling the filament back during non-extruding moves, to avoid stringing. Long in Bowden, very short in direct.</p>
<p><strong>Shore A</strong> — Hardness scale for elastomers. Suction-cup silicone is 40–60, a door seal 70, common printing TPU 95, a trolley wheel 98.</p>
<p><strong>STL</strong> — A file format describing a surface as a triangle mesh. What the slicer expects.</p>
<p><strong>Slicer</strong> — The software turning an STL into nozzle paths. Here PrusaSlicer, driven from the command line by a profile file.</p>
<p><strong>TPU</strong> — Thermoplastic polyurethane: the most common flexible filament.</p>
<h2 id="references">References and local copies</h2>
<ul>
<li>The model and its outputs: <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/ventouse.py"><code>ventouse.py</code></a>, <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/ventouse.stl"><code>ventouse.stl</code></a></li>
<li>The TPU slicing profile: <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/qqs-tpu.ini"><code>qqs-tpu.ini</code></a></li>
<li>A prismatic part made with the same toolchain: <a href="https://pf.olibrio.fr/assets/vibe-design-ventouse-tpu/esp32-supermini-tray.py"><code>esp32-supermini-tray.py</code></a></li>
<li><a href="https://github.com/elalish/manifold">manifold3d</a> — the solid geometry library used here</li>
<li><a href="https://www.klipper3d.org/Measuring_Resonances.html">Klipper documentation — measuring resonances</a> — on the 25 Hz threshold and the mass/stiffness trade-off</li>
<li><a href="https://pf.olibrio.fr/en/posts//en/posts/flsun-qqs-delta-klipper-calibration.html">The previous post</a> — calibrating the same machine, where the annotated-photo method was born</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>A European Pixel 3a running Linux: what Google abandoned in 2022 now runs a 2026 kernel</title>
      <link>https://pf.olibrio.fr/en/posts/pixel-3a-postmarketos-linux-mainline.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/pixel-3a-postmarketos-linux-mainline.html</guid>
      <pubDate>Mon, 07 Sep 2026 12:00:00 +0000</pubDate>
      <description>A European Pixel 3a (G020F), last updated by Google in May 2022, factory-reset and cracked. In one evening: postmarketOS with a mainline Linux 7.1.3 kernel, Phosh, SSH over the USB cable. The full story — why the mainline kernel rather than Ubuntu Touch, unlocking the bootloader, and the hour spent blaming the cable when the real culprit was kernel USB autosuspend relayed by TLP. And why this isn&#x27;t just a hobby: Chat Control, age verification, and the government ID Google will soon require from developers before you can install their apps.</description>
      <category>pixel 3a</category>
      <category>postmarketos</category>
      <category>mainline linux</category>
      <category>sdm670</category>
      <category>obsolescence</category>
      <category>fastboot</category>
      <category>usb autosuspend</category>
      <category>tlp</category>
      <category>libcamera</category>
      <category>phosh</category>
      <category>free software</category>
      <category>right to repair</category>
      <category>chat control</category>
      <category>age verification</category>
      <category>digital sovereignty</category>
      <category>grapheneos</category>
      <category>privacy</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/pixel3a/og-pixel3a.jpg" alt="A European Pixel 3a running Linux: what Google abandoned in 2022 now runs a 2026 kernel"></p>
<div class="tldr">
<p><strong>In two minutes</strong></p>
<ul>
<li><strong>Starting point</strong>: a 2019 Pixel 3a, European G020F model, last security update 5 May 2022, cracked screen, factory-reset. Officially: waste.</li>
<li><strong>End point</strong>, a few hours later: <strong>postmarketOS</strong> running a <strong>mainline Linux 7.1.3 kernel</strong> — not a patched-up Android kernel, the real one — the Phosh desktop, both cameras detected, and <code>ssh</code> access over the plain USB cable.</li>
<li><strong>The choice that matters</strong>: Ubuntu Touch makes everything work better, but it rests on frozen Android drivers. postmarketOS makes fewer things work, on code you can fix and send upstream. For anyone who wants to contribute, it&rsquo;s the only option.</li>
<li><strong>The hour lost</strong>: <code>fastboot</code> kept hanging without a single message. I blamed permissions, then the cable, then the USB port, then the binary&rsquo;s version. All four were wrong. The culprit: <strong>the kernel puts the USB interface to sleep after two seconds</strong>, the Pixel bootloader can&rsquo;t wake back up — and <strong>TLP</strong> was silently undoing my fix.</li>
<li><strong>What that reveals</strong>: this trap is documented nowhere. Someone who hits it concludes their cable is dead and gives up. A two-line fix settles it, for every Pixel.</li>
<li><strong>What&rsquo;s next</strong>: <code>cam -l</code> reproduces a known libcamera bug on first boot — and reveals a second, easier one that nobody had reported.</li>
<li><strong>What about your phone?</strong> postmarketOS covers 39 devices at <code>community</code> tier (fourteen of them phones) and 149 at <code>testing</code>; Ubuntu Touch lists 111. Full tables <a href="#what-about-your-phone">further down</a>.</li>
</ul>
<p><em>This post aims to be readable without knowing anything about Android or Linux: every technical term is explained the first time it appears. The &ldquo;Going deeper&rdquo; boxes fold out for those who want the exact commands.</em></p>
</div>
<p><img alt="The back of the Pixel 3a: moulded into the white plastic, the CE mark, the crossed-out wheelie bin, the words &quot;Model G020F&quot;, and Google's G" src="https://pf.olibrio.fr/images/pixel3a/dos-g020f-ce-weee.jpg" /></p>
<p><em>The back of the device. &ldquo;Model G020F&rdquo; — the European variant. Beside it, the CE mark and the crossed-out bin, that pictogram meaning &ldquo;do not dispose of with household waste&rdquo;. It took seven years for that symbol to stop being a formality and become the subject.</em></p>
<h2 id="what-end-of-support-actually-means">What &ldquo;end of support&rdquo; actually means</h2>
<p>The Pixel 3a came out in May 2019. It was the sensible one of its generation: a remarkable camera for €400, a headphone jack the flagships had already dropped, an unassuming Snapdragon 670 processor. Google updated it until <strong>5 May 2022</strong>. The last build installed on this one is numbered <code>SP2A.220505.008</code> — Android 12, May 2022 security patch.</p>
<p>Nothing since. Not because the hardware failed: it works perfectly. Because a company decided a period had elapsed.</p>
<p>&ldquo;The device is no longer supported&rdquo; is a sentence you read without thinking. Let&rsquo;s translate it. The processor, the screen, the cameras, the modem, the battery — all still work. What stops is a service: someone, somewhere, stops compiling software for this particular combination of chips. The phone doesn&rsquo;t become faulty, it becomes <strong>orphaned</strong>. And because the software it runs cannot be modified by its owner, orphaned means doomed.</p>
<p>Except this one was a Pixel. And Pixels have a rare property: Google, unlike almost all its competitors, lets the owner <strong>unlock the bootloader</strong> — the small program that decides which operating system is allowed to start. That permission, which I&rsquo;ll come back to, is what separates a recoverable device from a brick.</p>
<details>
<summary>Going deeper: what a bootloader actually locks</summary>
<p>When a phone powers on, a tiny program burned into the chip runs first, checks a cryptographic signature on the system that follows, and refuses to continue if it doesn&rsquo;t match. That&rsquo;s <strong>verified boot</strong>, and it&rsquo;s a good thing: it prevents malicious software from replacing your system behind your back.</p>
<p>The problem isn&rsquo;t the mechanism, it&rsquo;s <strong>who holds the key</strong>. On a locked device, only the manufacturer can sign a system. The day it stops producing them, the lock remains but nobody has the key any more. Security becomes a death sentence.</p>
<p>Google lets the owner disable that check, with a prominent warning and a full data wipe along the way — which is the right way to do it, since a thief can&rsquo;t unlock without destroying everything. The variants sold by certain US carriers, however, are sealed permanently. The difference isn&rsquo;t technical, it&rsquo;s commercial. On the back of mine, &ldquo;G020F&rdquo; means <em>Rest of World</em>, the European version. Sold carrier-free, therefore unlockable.</p>
</details>
<h2 id="three-ways-to-put-linux-on-a-phone-and-only-one-that-counts-here">Three ways to put Linux on a phone, and only one that counts here</h2>
<p>An Android phone already runs a Linux kernel. But an Android kernel is an old version of the official kernel, to which the chip maker has added thousands of changes it never publishes upstream. That code lives only as long as the product does. Which is the whole difference between the three options on the table:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th></th>
<th>postmarketOS</th>
<th>Ubuntu Touch</th>
<th>Droidian</th>
</tr>
</thead>
<tbody>
<tr>
<td>Base</td>
<td><strong>mainline Linux kernel</strong></td>
<td>frozen Android drivers (Halium)</td>
<td>frozen Android drivers</td>
</tr>
<tr>
<td>On the Pixel 3a</td>
<td><em>community</em> tier</td>
<td>stable, very polished</td>
<td>working</td>
</tr>
<tr>
<td>Calls, camera, fingerprint</td>
<td>partial</td>
<td><strong>everything works</strong></td>
<td>partial</td>
</tr>
<tr>
<td>Life expectancy</td>
<td>that of Linux itself</td>
<td>that of 2019 drivers</td>
<td>same</td>
</tr>
<tr>
<td>Fixable upstream</td>
<td><strong>yes</strong></td>
<td>no</td>
<td>no</td>
</tr>
</tbody>
</table></div>
<p>Ubuntu Touch is objectively the better choice for <em>using</em> this phone: calls, SMS, 4G, Bluetooth, NFC, both cameras, the fingerprint reader — it all works. But it gets there by reusing Android 9 binary drivers. That is, by embalming 2019. Nothing fixed there benefits anyone else, and the day that base rots, there&rsquo;ll be nothing to do about it.</p>
<p>postmarketOS takes the opposite road: run the <strong>official Linux kernel</strong>, the one everyone uses, on this chip. It&rsquo;s harder, things are missing, and that&rsquo;s precisely the point — what gets written there flows into the kernel the whole world will be running in ten years. A group of developers works on this specific processor under the name <strong>sdm670-mainline</strong>.</p>
<p>So the choice was simple, provided I accepted what it implies: this phone will not be my phone. It&rsquo;s a test bench.</p>
<h2 id="unlocking-and-the-factory-reset-trap">Unlocking, and the factory-reset trap</h2>
<p>The device arrived factory-reset, which seemed like a simplification. It wasn&rsquo;t.</p>
<p>To unlock the bootloader you must enable a setting called <strong>OEM unlocking</strong> in Android. That setting stays greyed out until the phone has reached the network at least once: Google checks the device isn&rsquo;t reported stolen. So you have to boot the phone, walk through the setup wizard, connect Wi-Fi — and only then enable the option. On a device you&rsquo;re about to wipe entirely, it&rsquo;s an absurd but mandatory detour.</p>
<p>Second trap, nastier: the key combination to enter <em>fastboot</em> mode — the bootloader&rsquo;s maintenance mode — is <strong>Volume Down + Power</strong>, but <strong>only from a completely powered-off phone</strong>. On a running device, that same combination takes a screenshot. You press, nothing expected happens, you try again, you start doubting your hardware. You must power off, wait two seconds, hold Volume Down <em>first</em>, then press Power.</p>
<p>The rest is one command, plus a red warning on screen to confirm with the physical buttons:</p>
<pre><code>fastboot flashing unlock
</code></pre>
<p><img alt="The Pixel 3a's cracked screen in fastboot mode, showing bootloader information: Product revision sargo MP1.0 (ROW), Secure boot PRODUCTION, and in red &quot;Device state: unlocked&quot;" src="https://pf.olibrio.fr/images/pixel3a/fastboot-unlocked.jpg" /></p>
<p><em>The fastboot screen after the operation. &ldquo;Device state: unlocked&rdquo; in red: the phone will now boot a system that Google hasn&rsquo;t signed. Note &ldquo;sargo MP1.0 (ROW)&rdquo; — sargo is the Pixel 3a&rsquo;s codename, ROW confirms the international variant.</em></p>
<p>The phone wipes everything and reboots. From there, it doesn&rsquo;t really belong to Google any more.</p>
<h2 id="building-the-system-half-an-hour-of-questions">Building the system: half an hour of questions</h2>
<p>On the computer side, postmarketOS is built with a tool called <code>pmbootstrap</code>, available in most distributions&rsquo; repositories. It doesn&rsquo;t download a ready-made image: it assembles the system for your exact device, in an isolated environment, asking you about twenty questions along the way.</p>
<p>Most call for the default. Three deserve thought, and I settled all three on the same principle: <strong>stick to what the maintainers test.</strong></p>
<p>That principle is worth spelling out, because it&rsquo;s counter-intuitive. When you install a system for yourself, you pick what you prefer. When you install it to contribute, every departure from the majority choice is one more variable that will make your bug reports useless. If I report an audio problem on a stack nobody else runs, the maintainer can neither reproduce it nor compare it. My report becomes noise.</p>
<p>So I took <code>pulseaudio</code> over the more modern <code>pipewire</code>, <code>wpa_supplicant</code> over <code>iwd</code>, the <strong>Phosh</strong> desktop over the very tempting <code>sxmo</code>, and — reluctantly — systemd. On that last point, postmarketOS is one of the few places where OpenRC remains a first-class citizen; my instinct wasn&rsquo;t fringe at all. But four of the fifteen open bugs for this device are reported under Phosh with systemd, and <code>journalctl</code> remains the handiest way to extract a clean trace.</p>
<p>Two answers deserve a warning.</p>
<p><strong>Don&rsquo;t enable disk encryption.</strong> The option is there, it&rsquo;s tempting, and on this phone it makes the device unbootable: an open bug describes a system that doesn&rsquo;t detect keyboard input at boot. You end up with a machine asking for a passphrase you cannot type.</p>
<p><strong>Keep the locale in English.</strong> Counter-intuitive for a francophone blog, but error messages and logs will come out in English, hence directly pasteable into a report and comparable with everyone else&rsquo;s. It in no way prevents an AZERTY keyboard.</p>
<details>
<summary>Going deeper: the exact answers, and which packages to bring along</summary>
<pre><code>Channel          edge          # where bugs get reported and fixes land
Vendor           google
Device           sargo
UI               phosh
Audio backend    pulseaudio    # default
WiFi backend     wpa_supplicant # default
usb-moded        developer     # USB networking ALWAYS on — the safety line
Service manager  default       # systemd for Phosh
Locale           en_US
</code></pre>
<p>The <code>usb-moded: developer</code> choice deserves emphasis: it keeps a network interface alive over the USB cable at all times. When the screen stops responding — and it will — <code>ssh</code> over the cable will be the only way in to collect logs. The other option, <code>charging</code>, would require enabling networking by hand from a possibly unusable phone.</p>
<p>For extra packages, I brought along enough to work from first boot:</p>
<pre><code>libcamera-tools,v4l-utils,evtest,tmux,vim,strace,usbutils
</code></pre>
<p><code>libcamera-tools</code> provides the <code>cam</code> command, which is exactly the tool named in the camera bug I wanted to reproduce. <code>v4l-utils</code> provides <code>v4l2-ctl</code>, the measuring instrument for the fix I was after. <code>evtest</code> is for touchscreen bugs. <code>tmux</code> lets an SSH session survive the cable being unplugged.</p>
<p>A note on method: <strong>don&rsquo;t guess package names.</strong> Alpine, the distribution postmarketOS is built on, doesn&rsquo;t always name them the way your usual distribution does. A wrong name fails the install after several minutes. The index is public and checks in ten seconds:</p>
<pre><code class="language-bash">curl -s -o idx.tar.gz \
  &quot;https://dl-cdn.alpinelinux.org/alpine/edge/community/aarch64/APKINDEX.tar.gz&quot;
tar xzf idx.tar.gz -O APKINDEX | grep '^P:' | sed 's/^P://' &gt; packages.txt
grep -qx &quot;libcamera-tools&quot; packages.txt &amp;&amp; echo present
</code></pre>
<p>That&rsquo;s how I found out <code>device-tree-compiler</code> is called <code>dtc</code> on Alpine.</p>
<p><img alt="Beavis, looking baffled, mouthing &quot;WHAT?&quot;" src="https://pf.olibrio.fr/images/pixel3a/dtc-what.gif" /></p>
<p><em>Which is fine in English. In French slang, <code>dtc</code> is an abbreviation you would not say to your mother, and I did read the command twice before running it.</em></p>
</details>
<p>Half an hour later the system was built. It just had to be written to the phone. That&rsquo;s where the evening derailed.</p>
<h2 id="an-hour-blaming-the-wrong-culprit">An hour blaming the wrong culprit</h2>
<p>The phone was in fastboot mode, plugged in, recognised. The write command would start — and hang. Indefinitely. <strong>Without a single error message</strong>, on standard output or on standard error. A sleeping process, forever.</p>
<p>What made it disorienting was that the device seemed perfectly present:</p>
<pre><code>$ fastboot devices
058AY1WZGT     fastboot
</code></pre>
<p>It answered. It was there. And yet every write hung.</p>
<p>I formed four theories. All four were wrong, and ruling them out took an hour. They&rsquo;re worth listing, because they&rsquo;re exactly the ones anyone would form.</p>
<p><strong>A permissions problem.</strong> The classic: the USB node belongs to <code>root</code>, the user has no write access. Checked — the file carried an access control list explicitly granting my account. Ruled out by measurement, not by assumption.</p>
<p><strong>A bad cable or port.</strong> That&rsquo;s the next reflex, and the most common answer on forums. Except the kernel logs were impeccably clean: high-speed enumeration, no error, no reset, no spurious disconnect. A damaged cable leaves traces; there were none.</p>
<p><strong>A USB controller problem.</strong> A serious lead: Pixels are USB 2.0 only, and modern controllers are known to be temperamental with certain bootloaders. The usual advice is &ldquo;plug into a USB 2.0 port&rdquo;. Two measurements ruled it out: the phone was already negotiating at 480 megabits, so already USB 2.0, and the machine&rsquo;s motherboard (an Intel Raptor Lake) no longer has a legacy companion controller — everything goes through the same block. &ldquo;Switch to a USB 2.0 port&rdquo; was meaningless.</p>
<p><strong>A software regression.</strong> <code>pmbootstrap</code> runs its own copy of <code>fastboot</code>, version 37, whereas my system had version 35. So I bypassed the tool and flashed directly with the system&rsquo;s binary. <strong>It hung in exactly the same way.</strong> Theory dead.</p>
<p>Four leads, four dead ends. And one detail that should have alerted me far sooner: on every fresh entry into fastboot mode, <strong>the first command went through</strong> — in thirteen milliseconds — and every one after it hung.</p>
<p>That fact had been in front of me from the start. It took me an hour to listen to it, because I kept questioning the device instead of questioning the system.</p>
<h2 id="the-answer-was-sitting-in-a-file">The answer was sitting in a file</h2>
<p>Fastboot mode is a conversation: the computer asks, the bootloader answers. I eventually looked not at what the phone was answering, but at <strong>what the Linux kernel thought of it</strong>. That information lives in <code>/sys</code>, a virtual filesystem where the kernel exposes its internal state as readable files.</p>
<pre><code>$ cat /sys/bus/usb/devices/1-9/power/control
auto
$ cat /sys/bus/usb/devices/1-9/power/autosuspend_delay_ms
2000
$ cat /sys/bus/usb/devices/1-9/power/runtime_status
suspended
</code></pre>
<p><strong>Asleep.</strong></p>
<p>To save power, the kernel suspends USB devices after an idle delay — here, two seconds. That&rsquo;s normal and desirable behaviour for most devices, which know how to wake when spoken to again. <strong>The Pixel bootloader doesn&rsquo;t.</strong> Once asleep, it never answers again.</p>
<p>Everything falls into place at once:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>What I saw</th>
<th>What was happening</th>
</tr>
</thead>
<tbody>
<tr>
<td>First command works, the rest hang</td>
<td>The phone falls asleep in between</td>
</tr>
<tr>
<td><code>fastboot devices</code> still lists it</td>
<td>Enumeration is cached, it needs no exchange</td>
</tr>
<tr>
<td>Unlocking had worked</td>
<td>It was the first command after entering fastboot</td>
</tr>
<tr>
<td>Leaving and re-entering fastboot &ldquo;fixed&rdquo; it</td>
<td>A new enumeration wakes the device, for one command</td>
</tr>
<tr>
<td>Both <code>fastboot</code> versions failed identically</td>
<td>The binary had nothing to do with it</td>
</tr>
<tr>
<td>Kernel logs stayed clean</td>
<td>Suspending isn&rsquo;t an error, it&rsquo;s normal operation</td>
</tr>
</tbody>
</table></div>
<p>The fix is a three-line rule telling the kernel never to suspend Google devices:</p>
<pre><code># /etc/udev/rules.d/52-fastboot-no-autosuspend.rules
ACTION==&quot;add&quot;, SUBSYSTEM==&quot;usb&quot;, ATTR{idVendor}==&quot;18d1&quot;, \
    TEST==&quot;power/control&quot;, ATTR{power/control}=&quot;on&quot;
</code></pre>
<p>Except it changed nothing. And the reason it failed is the most interesting part of the story.</p>
<h2 id="the-second-culprit-hiding-behind-the-first">The second culprit, hiding behind the first</h2>
<p>My rule was in fact correct — udev&rsquo;s own test tool confirmed it was being evaluated and applied. But after each plug-in, the setting fell back to <code>auto</code>.</p>
<p>Someone was coming along behind me. That someone was <strong>TLP</strong>, a power manager widespread on Linux laptops, which applies its own USB autosuspend settings <em>after</em> udev and therefore silently overwrites yours. Plenty of people install it once for battery life and then forget about it entirely.</p>
<p>TLP has an option meant precisely for this:</p>
<pre><code># /etc/tlp.conf
USB_EXCLUDE_PHONE=1
USB_DENYLIST=&quot;18d1:4ee0 18d1:4ee1 18d1:4ee2 18d1:4ee3 18d1:4ee7&quot;
</code></pre>
<p>This time the setting held. One detail worth knowing: <strong>applying the fix does not unstick an already-sleeping device.</strong> The bootloader&rsquo;s USB state only recovers on re-enumeration. So you fix it, <em>then</em> leave and re-enter fastboot mode.</p>
<p>And to maximise the odds, I grouped the three writes into a single invocation rather than three successive processes:</p>
<pre><code class="language-bash">fastboot flash vbmeta   vbmeta.img \
         flash boot     boot.img \
         flash userdata google-sargo.img
</code></pre>
<pre><code>Sending 'vbmeta_b' (4 KB)                   OKAY [  0.120s]
Writing 'vbmeta_b'                          OKAY [  0.071s]
Sending 'boot_b' (28008 KB)                 OKAY [  0.747s]
Writing 'boot_b'                            OKAY [  0.194s]
Sending sparse 'userdata' 1/9 (258751 KB)   OKAY [  6.306s]
...
Finished. Total time: 94.883s
</code></pre>
<p>Ninety-five seconds. After an hour of deadlock.</p>
<details>
<summary>Going deeper: diagnosing a fastboot that hangs</summary>
<p>Three reflexes, in this order:</p>
<p><strong>1. Check the device is genuinely there before interpreting anything.</strong> A <code>&lt; waiting for any device &gt;</code> often means the phone simply left fastboot mode, not that there&rsquo;s an access problem. I nearly concluded there was an inverted permissions issue based on a test where the device was in fact absent.</p>
<pre><code class="language-bash">lsusb | grep 18d1
</code></pre>
<p><strong>2. Look at the kernel&rsquo;s state, not just the command&rsquo;s output.</strong></p>
<pre><code class="language-bash">cat /sys/bus/usb/devices/&lt;port&gt;/power/runtime_status
</code></pre>
<p><strong>3. Inspect the hung process.</strong> It tells you whether it opened the device — in which case it&rsquo;s waiting for an answer that will never come — and what environment it runs in:</p>
<pre><code class="language-bash">sudo readlink /proc/&lt;pid&gt;/root        # is it running in a chroot?
sudo ls -l /proc/&lt;pid&gt;/fd | grep usb  # did it open the device?
</code></pre>
<p>Two shell traps I hit along the way, which cost me time:</p>
<ul>
<li><code>rc=$?</code> after a pipe captures the <strong>last element&rsquo;s</strong> exit code, never <code>timeout</code>&rsquo;s. My diagnostic probe was therefore printing &ldquo;OK&rdquo; on commands that were hanging. Redirect to files and test directly, or use <code>set -o pipefail</code>.</li>
<li><code>pkill -f 'some pattern'</code> kills the calling shell when the pattern appears in its own command line. Prefer a loop over <code>pgrep -x</code>.</li>
</ul>
</details>
<h2 id="what-booted">What booted</h2>
<p>Twenty seconds after the reboot, the machine saw a network interface appear. The phone was handing out addresses itself.</p>
<pre><code>$ ssh sam@172.16.42.1
$ uname -r
7.1.3-sdm670
$ df -h /
/dev/loop0p2   47.9G   2.2G   43.2G   5% /
</code></pre>
<p><strong>Kernel 7.1.3.</strong> On a phone whose manufacturer stopped caring in May 2022, with an Android kernel frozen at 4.9. The filesystem had grown by itself on first boot to fill the available 48 gigabytes.</p>
<p><img alt="Phosh's welcome screen on the cracked Pixel 3a: &quot;Welcome — Get to know the features of your Phone and Phosh&quot;" src="https://pf.olibrio.fr/images/pixel3a/phosh-welcome.jpg" /></p>
<p><em>First boot. The screen has been cracked for a long time; the device itself has just got four years younger.</em></p>
<p><img alt="Phosh's quick settings panel: Wi-Fi on, Bluetooth on, battery 94%, and a notification reading &quot;USB Mode Selector — USB Developer mode&quot;" src="https://pf.olibrio.fr/images/pixel3a/phosh-quicksettings.jpg" /></p>
<p><em>The quick settings panel. Wi-Fi, Bluetooth, battery at 94%. And in the notifications, the &ldquo;USB Developer mode&rdquo; selector — exactly the profile chosen at install time, the one that keeps USB networking permanently on. The clock reads &ldquo;Thursday, January 1, 4:12 AM&rdquo;: the hardware clock hasn&rsquo;t been set yet, nobody has told it what day it is.</em></p>
<h2 id="the-camera-or-how-a-well-written-tool-hands-you-the-work">The camera, or how a well-written tool hands you the work</h2>
<p>All that remained was to check what I&rsquo;d come for. On postmarketOS, cameras go through <strong>libcamera</strong>, a free library replacing Android&rsquo;s proprietary stack. A known bug reports that this phone&rsquo;s front sensor driver is incomplete, and that <code>cam -l</code> — which lists cameras — complains about it.</p>
<p>Complain it did. But far better than I&rsquo;d hoped:</p>
<pre><code>ERROR V4L2 'imx355 4-001a': Unable to get rectangle 2 on pad 0/0
ERROR V4L2 'imx355 4-001a': Unable to get rectangle 1 on pad 0/0
ERROR V4L2 'imx355 4-001a': Unable to get rectangle 0 on pad 0/0
 WARN  'imx355 4-001a': Failed to retrieve the sensor crop rectangle
 WARN  'imx355 4-001a': The sensor kernel driver needs to be fixed
 WARN  See Documentation/sensor_driver_requirements.rst
</code></pre>
<p>There&rsquo;s something delightful about software that literally tells you &ldquo;the sensor kernel driver needs to be fixed&rdquo; and hands you the reference to the document explaining how. It is the exact opposite of <a href="https://pf.olibrio.fr/en/posts/erreur-4202-neato-obsolescence.html">error 4202 on a vacuum cleaner</a>.</p>
<p>Concretely: the driver can&rsquo;t answer when asked for the real dimensions of its pixel array. libcamera then invents defaults and works on approximate geometry.</p>
<p>But the output also revealed this, which I wasn&rsquo;t expecting:</p>
<pre><code>WARN 'imx363': No static properties available — Please consider updating the database
WARN 'imx355': No static properties available
WARN IPASoft: Failed to create camera sensor helper for imx355 / imx363
</code></pre>
<p>This second problem isn&rsquo;t in the kernel. It&rsquo;s two data tables inside libcamera itself, where the Pixel 3a&rsquo;s sensors are missing — while dozens of others are already there, providing a template to copy. Pure data, no algorithms.</p>
<p><strong>Update, a few hours later.</strong> Going to write that patch, I found half of it had just been done: the front sensor, the <strong>imx355</strong>, was added upstream <em>after</em> the 0.7.2 release this phone runs. The warning above is therefore a version artefact, not a gap — an update will settle it. The rear sensor, the <strong>imx363</strong>, is still absent from the whole repository, and its driver isn&rsquo;t even in the official kernel: it was written at Intel, never sent upstream, and postmarketOS loads it as a module.</p>
<p>And comparing the fresh imx355 entry against what the driver actually exposes on the device, something is off: it maps &ldquo;colour bars&rdquo; to value 1 and &ldquo;solid colour&rdquo; to value 2, where the driver does exactly the reverse. Two other sensors with an identical menu, the imx258 and the imx471, are described correctly right beside it. So that&rsquo;s two patches instead of one — and the easier one is the patch nobody was expecting.</p>
<p>In other words: the first patch to write isn&rsquo;t the one I came for. It took powering on the real hardware to notice. That&rsquo;s a fairly general lesson — you can read bug reports for days without seeing what a running machine tells you in three seconds.</p>
<h2 id="why-this-isnt-just-a-hobby">Why this isn&rsquo;t just a hobby</h2>
<p>The very morning this phone booted, the account <code>balade.nomade</code> posted <a href="https://www.instagram.com/reel/Dc-vV23MGIi/">a reel</a> (<a href="https://pf.olibrio.fr/assets/pixel3a/refs/balade-nomade-reel-2026-09-07.md">local copy of the caption and comment thread</a>) summarising what&rsquo;s currently in play in Brussels. It ends on a question I think is well put:</p>
<blockquote>
<p>Are you ready to scan your ID card in order to use Instagram?</p>
</blockquote>
<p>The tone is activist, so I checked the three claims. Two hold up solidly, the third deserves a caveat — and the comment thread added a fourth, more relevant to this post than the other three.</p>
<p><strong>Chat Control.</strong> Accurate. The EU regulation known as CSAR remains stuck in trilogue, with negotiations resuming in late September 2026 under the Irish Council presidency. On 9 July 2026, the European Parliament voted 314 to 276 to strip out the mechanism — short of the 360 votes needed to block the Council&rsquo;s position. The derogation permitting &ldquo;voluntary&rdquo; message scanning was therefore extended to 2028. Client-side scanning was removed from the latest draft, but the Council&rsquo;s own legal service, in an opinion dated 10 June 2026, considers that voluntary scanning still amounts to a generalised search of communications, incompatible with Article 7 of the Charter of Fundamental Rights.</p>
<p><strong>The end of anonymity.</strong> Accurate, with a development the reel doesn&rsquo;t mention: the French law of 21 July 2026 banning social media for under-fifteens was <strong>struck down on 14 August by the Conseil constitutionnel</strong> (decision no. 2026-911 DC) as a disproportionate infringement of freedom of expression and privacy. It will not take effect as drafted. But the underlying movement continues elsewhere: the European Commission announced on 15 April 2026 that its age-verification solution was ready for deployment, and the European digital identity wallet is due in member states by year&rsquo;s end.</p>
<p><strong>Metadata.</strong> Here I&rsquo;ll add a caveat. The EDPB did adopt new anonymisation guidelines on 7 July 2026, open for consultation until 30 October, replacing the 2014 reference opinion. But it&rsquo;s a technical clarification, not a fist on the table. And recent case law points the other way: the Court of Justice of the European Union, in September 2025, adopted a <em>relative</em> approach — the same pseudonymised data can be anonymous to a party with no means of re-identification, and personal to another.</p>
<h3 id="the-fourth-point-the-one-thats-really-about-this-post">The fourth point, the one that&rsquo;s really about this post</h3>
<p>In the comments, someone flags that Google is changing its app installation policy and worries this will &ldquo;put GrapheneOS in danger&rdquo;. The author&rsquo;s reply rightly corrects the fear, and the facts bear them out.</p>
<p>Since August 2025, Google has required that <strong>every app installed on a certified Android device come from a verified developer</strong> — including when you install an APK file by hand, outside any store. The first user-visible restrictions land on <strong>30 September 2026</strong> in Brazil, Indonesia, Singapore and Thailand, before a global rollout in 2027. Installing an app from an unverified developer will go through a roundabout flow with a <strong>mandatory twenty-four-hour waiting period</strong>. And to become verified, a developer must open an account, pay twenty-five dollars and supply <strong>government-issued identity documents</strong>.</p>
<p>Reread the reel&rsquo;s question, and swap the user for the developer. It&rsquo;s the same gesture: an identity document as a condition of access. One side to read, the other to write.</p>
<p>The commenter was wrong on one point, though, and it&rsquo;s the one that matters. The requirement applies to <strong>certified</strong> devices — those shipping Google&rsquo;s services under licence. GrapheneOS doesn&rsquo;t ship them: it falls outside the perimeter. So the restriction doesn&rsquo;t endanger de-Googled systems, <strong>it makes their existence more necessary</strong>.</p>
<h3 id="installing-your-own-software-that-fight">Installing your own software, that fight</h3>
<p>This is what exasperates me most, and it predates all of this news. On Android as on iOS, installing software you compiled yourself, or pulled from a Git repository, is an obstacle course.</p>
<p>On Android you have to allow an unknown source, click past three warnings, and soon dig through the developer options and then wait twenty-four hours. On iOS it&rsquo;s worse: an app you compile and sign with a free Apple account <strong>stops working after seven days</strong> and must be reinstalled from a computer. For it to survive, you pay ninety-nine euros a year. The European Digital Markets Act cracked the door open in 2024 — alternative marketplaces, distribution from your own website — but only within the Union, and on Apple&rsquo;s terms.</p>
<p>The justification is always the same: security. It deserves to be taken seriously, and therefore examined.</p>
<p>If openness caused insecurity, we&rsquo;d see it. Linux lets you install anything from anywhere — a repository, an archive, code you compiled by hand — and it isn&rsquo;t a security disaster: it&rsquo;s the system running most of the planet&rsquo;s servers, under permanent attack. Windows, conversely, long had the most permissive model <strong>and</strong> the worst reputation. If the theory held, Android and iOS would be the safest systems ever built. What we observe is roughly the opposite of what it predicts.</p>
<p>So what protects isn&rsquo;t closure, it&rsquo;s <strong>a verifiable chain of trust</strong>: signed repositories, identified maintainers, source code anyone can read, reproducible builds. Security comes from transparency, not from permission. And that chain already exists on Android: F-Droid has for years distributed free software built from public sources, with no toll and no identity document.</p>
<p>Meanwhile, the official store lets things through. In 2026, a malware family named NoVoice was found in more than fifty Play Store apps, totalling at least 2.3 million downloads — with root access and surviving a factory reset. The year before, the SlopAds campaign: 224 apps, 38 million downloads. And Google banned <strong>80,000 developer accounts in 2025</strong>, under a regime that already requires identity verification to publish on the Play Store.</p>
<p>That figure settles the argument. Developer verification already exists where it&rsquo;s meant to protect, and eighty thousand accounts still have to be banned every year. Extending it to manual installation won&rsquo;t stop the industrial campaigns — they come through the front door, and it&rsquo;s documented. It will stop the lone developer publishing their code to a Git repository.</p>
<p>The risk itself is real: someone installing a file received by text message does indeed get caught. But that argues for a clear warning, not an entrance fee. You protect someone by telling them what they&rsquo;re doing; you don&rsquo;t protect them by charging the person who writes.</p>
<p>And there&rsquo;s one criterion that settles the question. To operate an alternative marketplace in the European Union, Apple requires, as of <strong>1 October 2026</strong>, that you be publicly traded, or venture-funded, or have passed a financial audit, or total one million annual installs. Not one of those criteria measures the security of anything. They are criteria of <strong>size</strong>.</p>
<p>I&rsquo;m willing to grant good faith on the principle. I have more trouble when the same company locks the door and holds the till.</p>
<h3 id="what-that-changes-for-an-old-phone">What that changes for an old phone</h3>
<p>The common thread is technical, and it&rsquo;s what connects this news to a 2019 Pixel.</p>
<p>Client-side scanning means inspecting messages <strong>on the device, before encryption</strong>. Such a mechanism isn&rsquo;t implemented in an app: it&rsquo;s implemented in the system. That&rsquo;s the decisive shift. As long as the guarantee rested on the protocol, it could be audited from outside. Once it rests on the device, the only question that matters becomes: <em>who decides what this device does?</em></p>
<p>On a phone whose system cannot be replaced, the answer is: the manufacturer. And through it, whoever legislates on the manufacturer. That&rsquo;s not a scandal, it&rsquo;s a perfectly ordinary chain of decision — but at no point does it pass through the owner.</p>
<p>Both existing escape hatches — GrapheneOS, postmarketOS — rest on exactly the same thing: <strong>a bootloader you&rsquo;re allowed to unlock</strong>. The same checkbox in the developer options that opened this evening. The entire practical way out, for Android, has so far hung on one manufacturer&rsquo;s goodwill about one setting. GrapheneOS runs on Pixels only; it will take Motorola&rsquo;s 2027 flagships for that to stop being true.</p>
<p>And I should say what this post does not demonstrate. <strong>postmarketOS on a Pixel 3a is not a privacy solution.</strong> The modem remains a black box, an autonomous processor running proprietary software the system has no access to. This particular device isn&rsquo;t usable day to day, as I said from the outset. For real-world use, the serious answer is the one the reel puts in a hashtag: GrapheneOS.</p>
<p>What this post does demonstrate is more modest, and enough: <strong>the capability exists, it&rsquo;s within one evening&rsquo;s reach, and it gets exercised.</strong> A capability never exercised eventually disappears without anyone noticing — not by prohibition, simply because one day no device offers it any more and nobody will have asked for it.</p>
<h2 id="what-about-your-phone">What about your phone?</h2>
<p>That was the first question I was asked, and it&rsquo;s the right one. Here&rsquo;s what answers it — the figures come from the <code>pmaports</code> repository as of 5 September 2026 and from Ubuntu Touch&rsquo;s own site, not from a list copied off somewhere.</p>
<p>First, a warning about vocabulary. postmarketOS sorts its devices into three tiers, and they mean something:</p>
<ul>
<li><strong><code>community</code></strong> — broadly works, with identified gaps. <strong>39 devices</strong>, of which only fourteen are phones.</li>
<li><strong><code>testing</code></strong> — anything from &ldquo;it boots, in some sense&rdquo; to &ldquo;almost everything works&rdquo;. <strong>149 devices.</strong> That&rsquo;s where most of the world&rsquo;s handsets sit, and where the work is.</li>
<li><strong><code>downstream</code></strong> — original Android kernel, very limited functionality. <strong>20 devices</strong>, not recommended.</li>
</ul>
<p>No device currently sits above <code>community</code>. That&rsquo;s an honest measure of where the ecosystem stands.</p>
<h3 id="the-fourteen-phones-in-community">The fourteen phones in <code>community</code></h3>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Year</th>
<th>Device</th>
</tr>
</thead>
<tbody>
<tr>
<td>2009</td>
<td>Nokia N900</td>
</tr>
<tr>
<td>2012</td>
<td>Samsung Galaxy S III</td>
</tr>
<tr>
<td>2014</td>
<td>Samsung Galaxy Core Prime VE LTE</td>
</tr>
<tr>
<td>2018</td>
<td>OnePlus 6 · OnePlus 6T · Samsung Galaxy S9 · Xiaomi Poco F1</td>
</tr>
<tr>
<td>2019</td>
<td><strong>Google Pixel 3a</strong> · Pixel 3a XL · Purism Librem 5</td>
</tr>
<tr>
<td>2020</td>
<td>SHIFT6mq</td>
</tr>
<tr>
<td>2021</td>
<td><strong>Fairphone 4</strong> · PinePhone Pro</td>
</tr>
</tbody>
</table></div>
<p>Two surprises there. The <strong>Xiaomi Poco F1</strong> and the <strong>OnePlus 6 / 6T</strong> are excellent candidates: powerful for their age, very common second-hand, and ported for years. And the <strong>original PinePhone</strong>, designed for Linux, sits in <code>testing</code> — it&rsquo;s the Pro successor that made <code>community</code>.</p>
<p>The rest of the tier isn&rsquo;t phones: a dozen ARM <strong>Chromebooks</strong>, the Lenovo ThinkPad X13s, the PineNote, the Odroid XU4, the RockPro64. If you&rsquo;re after a small ARM Linux computer rather than a phone, that&rsquo;s a badly underrated route.</p>
<p>In <code>testing</code>, the 149 devices break down mostly across <strong>Samsung</strong> (30), <strong>Xiaomi</strong> (18), <strong>Sony</strong>, <strong>OnePlus</strong>, <strong>LG</strong> and <strong>Google</strong> (5 each), and <strong>Fairphone</strong> (4). So there&rsquo;s a good chance your phone is in there — with work left to do on it, which is precisely the point.</p>
<h3 id="if-you-want-a-phone-that-works-not-a-test-bench">If you want a phone that works, not a test bench</h3>
<p>That&rsquo;s a different need, with different answers. <strong>Ubuntu Touch</strong> lists <strong>111 supported devices</strong>, with a per-device functionality score. The best served:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Device</th>
<th>Working</th>
</tr>
</thead>
<tbody>
<tr>
<td>Lenovo Tab M10 HD 2nd gen (WiFi)</td>
<td>100%</td>
</tr>
<tr>
<td>BQ Aquaris M10 HD / FHD</td>
<td>98.7%</td>
</tr>
<tr>
<td><strong>Fairphone 5</strong> (2023)</td>
<td>97.4%</td>
</tr>
<tr>
<td>Xiaomi Redmi Note 9 Pro · Poco X3 NFC</td>
<td>97.4%</td>
</tr>
<tr>
<td>OnePlus Nord N10 5G · Nord N100</td>
<td>97.4%</td>
</tr>
<tr>
<td><strong>Fairphone 4</strong> · Pixel 3a / 3a XL</td>
<td>97.3%</td>
</tr>
<tr>
<td><strong>Nothing Phone (1)</strong> (2022)</td>
<td>95.6%</td>
</tr>
<tr>
<td>Fairphone 3 / 3+</td>
<td>95.6%</td>
</tr>
</tbody>
</table></div>
<p>The <strong>Fairphone 4</strong> is the balance point in all this: it&rsquo;s in postmarketOS <code>community</code> <strong>and</strong> at 97.3% on Ubuntu Touch, and it&rsquo;s the most repairable phone on the market besides. If someone asked me what to buy to do this seriously, that would be it — or the Fairphone 5 if the goal is mainly to use the thing.</p>
<p>And if you want to stay on Android while getting out from under Google, <strong>GrapheneOS</strong> remains the soundest answer — but only on Pixel 6 and later, with seven years of guaranteed updates on the Pixel 8, 9 and 10. The Motorola partnership announced in August 2026 should end that exclusivity in 2027; the official device list, today, is still Pixel-only.</p>
<h3 id="how-to-check-for-yours">How to check for yours</h3>
<p>Look up your device&rsquo;s codename — not its marketing name — on the postmarketOS wiki and on <code>devices.ubuntu-touch.io</code>. The codename takes one command, with the device plugged in over USB:</p>
<pre><code class="language-bash">adb shell getprop ro.product.device
</code></pre>
<p>On mine it answers <code>sargo</code>. And two preconditions apply to everyone, whatever the list says: <strong>the bootloader must be unlockable</strong>, which rules out most devices sold by US carriers; and the operation <strong>wipes everything</strong>.</p>
<h2 id="what-i-take-away">What I take away</h2>
<p>The technical part is almost incidental. Three things stay with me.</p>
<p><strong>The trap is nowhere.</strong> USB autosuspend is mentioned in no installation guide. Someone who hits it sees a command hang without a message, tries another cable, another port, and concludes their hardware is at fault. They give up. The fix is two lines and applies to every Pixel. That&rsquo;s what I&rsquo;ll write first — before any code, before the camera. One evening lost by me can spare a great many others, and it&rsquo;s probably the highest-yield contribution in the whole story.</p>
<p><strong>I looked in the wrong place for an hour.</strong> Not for lack of method, but because I kept questioning the device — retry the command, change a parameter, try again — instead of questioning the system driving the device. The answer was in a nine-character text file. Whenever a tool goes silent instead of failing, stop relaunching it and go read the state of the layer beneath.</p>
<p><strong>And then there&rsquo;s the substance.</strong> This phone has a crossed-out wheelie bin moulded into its back, the pictogram meaning don&rsquo;t throw it out with the rubbish. The symbol has been there since 2019, mandatory, decorative. Tonight it became accurate: the device didn&rsquo;t go to the skip, it runs, and it runs a newer kernel than plenty of machines still in service.</p>
<p>This isn&rsquo;t a technical feat — far more capable people did the hard work of porting a modern kernel to this chip. All I did was follow their tracks and walk into a trap they hadn&rsquo;t documented. But it is a demonstration: end of support is not a property of the hardware. It&rsquo;s a decision. And when the manufacturer leaves the door open — as Google does on Pixels, to its credit — that decision can be taken up by someone else.</p>
<p>A second-hand Pixel 3a goes for a few tens of euros. There are millions of them in drawers.</p>
<p>Next up: the camera.</p>]]></content:encoded>
    </item>
    <item>
      <title>Six years of disappointing prints: what my FLSun QQ-S delta really had, and how we calibrated it for good</title>
      <link>https://pf.olibrio.fr/en/posts/flsun-qqs-delta-klipper-calibration.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/flsun-qqs-delta-klipper-calibration.html</guid>
      <pubDate>Sun, 06 Sep 2026 12:00:00 +0000</pubDate>
      <description>A 2020 FLSun QQ-S delta printer, &#x27;always a bit disappointing&#x27; next to a Bambu Lab. Switch to Klipper, then an investigation: an extruder under-extruding by 11.5% since day one, tower angles never calibrated, a motor current the official Klipper config doesn&#x27;t set, a levelling method that lied. The story, the numbers, the tools and the full procedure to reproduce it — no probe, just a sheet of paper.</description>
      <category>3d printing</category>
      <category>delta</category>
      <category>flsun qq-s</category>
      <category>klipper</category>
      <category>calibration</category>
      <category>bed mesh</category>
      <category>pressure advance</category>
      <category>mks robin mini</category>
      <category>repetier</category>
      <category>diy</category>
      <category>ai</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/flsun/og-flsun.jpg" alt="Six years of disappointing prints: what my FLSun QQ-S delta really had, and how we calibrated it for good"></p>
<div class="tldr">
<p><strong>In two minutes, if you have a delta that &ldquo;prints so-so&rdquo; and no setting ever changes anything</strong></p>
<ul>
<li><strong>The symptom</strong>: an FLSun QQ-S bought in 2020, 233 hours and 970 m of filament, never really satisfying. Weak walls, dimensions off by 2%, a first layer that could never be dialled in everywhere at once. Next to a Bambu Lab, it looked sad.</li>
<li><strong>What we found, in order</strong>: an extruder pushing <strong>11.5% less material</strong> since day one (a wrong value in the firmware&rsquo;s memory); <strong>tower angles never calibrated</strong> (+1.02° and +0.45°, every correction at zero in the EEPROM); and, when switching to Klipper, a <strong>motor current the board sets by PWM</strong> that the official Klipper config for this printer does not set — motors without torque, homing impossible.</li>
<li><strong>What worked</strong>: recalibrating everything <strong>hot</strong>, in the state the machine prints in, with <strong>a single measurement method</strong> (the sheet of paper), in the order geometry → zero → bed grid → first layer. Thirty-five minutes. A control spiral uniform from edge to edge, a cube at 20.00 mm in Z.</li>
<li><strong>What did not work</strong>, and cost two days: correcting the bed grid on top of a wrong geometry, with a micrometer method that was wrong precisely where the nozzle was too close. The details are in <a href="#false-trail">The false trail</a>, because it is the most instructive part.</li>
<li><strong>To reproduce</strong>: <a href="#to-reproduce">the full procedure</a>, the configuration files, the scripts and the test parts are <a href="#references">at the end of the post</a>. No probe, no accelerometer, a sheet of paper and a caliper.</li>
</ul>
<p><em>This post is meant to be readable without knowing anything about delta printers: every term is explained the first time it appears, and a glossary sums up at the end. The &ldquo;Going deeper&rdquo; boxes unfold for those who want the kinematics, the values and the commands.</em></p>
</div>
<p><img alt="FLSun QQ-S calibration bench: caliper, inch micrometer, 20 mm cube, first-layer frames and numbered thickness squares" src="https://pf.olibrio.fr/images/flsun/00-etabli.jpg" /></p>
<p><em>The bench on day three. Everything that served as a judge: the caliper, an old inch micrometer, the 20 mm cube, the first-layer frames, the thickness squares numbered in marker, and behind, the seven-pillar part of the extended calibration.</em></p>
<h2 id="an-always-a-bit-disappointing-machine">An &ldquo;always a bit disappointing&rdquo; machine</h2>
<p>I have an FLSun QQ-S delta printer, bought in January 2020. A <strong>delta</strong> is that triangular-tower printer where the nozzle hangs from three articulated arms that ride up and down three columns; there are no separate X, Y and Z axes as on a Cartesian printer — the nozzle position is the result of a calculation from the height of the three carriages. It is fast, quiet, elegant, and far more sensitive to geometry errors than a straight-axis machine: an arm one millimetre too long or a column one degree off does not give a simple error but a bowl, a dome or a three-lobed clover that varies across the whole bed.</p>
<p>Rarely used, this machine had always been a bit disappointing. Not catastrophic: parts came out. But the walls lacked strength, dimensions were off by a few tenths, the first layer never stuck everywhere, and above all <strong>no setting had a clear effect</strong>. I had ended up printing with a bigger nozzle &ldquo;so it would go faster&rdquo;, replaced the belts after destroying a set by over-tensioning, and parked the machine next to a Bambu Lab that simply prints right without being asked.</p>
<p>That comparison is what launched the investigation. Not &ldquo;how do I tune my slicer&rdquo;, but <strong>what exactly does this machine have</strong>, and what can be recovered. This post tells three days, 4 to 6 September 2026, carried out with an AI (Claude) that drives the printer, writes the tools and reads the manuals, while I hold the caliper, the sheet of paper and the camera. It also tells a fine methodological mistake, hers and mine, because that is what happens when you search for real.</p>
<h2 id="the-setup">The setup</h2>
<p>The original firmware was going to be replaced by <strong>Klipper</strong>. Klipper is a 3D printer firmware in two parts: a tiny program on the printer&rsquo;s board that only drives motors and heaters in real time, and a host program on a computer next to it that does everything else — kinematics, calibrations, macros. You lose the printer&rsquo;s touch screen (Klipper cannot drive it), you gain calibration tools that exist nowhere else, especially for deltas.</p>
<p>What Klipper brings, concretely, compared with the original Repetier — or a Marlin:</p>
<ul>
<li><strong>Offloaded computation.</strong> A classic firmware does all the delta kinematics on the board&rsquo;s small microcontroller, with the approximations that imposes; Klipper does it on the host computer, in floating point, and only sends the board precomputed step times. On a delta, where every move is a square root per column, that shows in movement precision and achievable speed.</li>
<li><strong>Calibration tools</strong>: <code>DELTA_CALIBRATE</code>, which derives the full geometry from seven points, with or without a probe; the extended arm calibration; the bed mesh; and <code>TUNING_TOWER</code>, which varies a parameter with height during a print, to settle in one part what would take ten tries.</li>
<li><strong>Pressure advance and input shaper</strong>, compensation for the pressure in the bowden and for vibrations, which the original firmware lacks and which account for most of the quality gap with a modern machine.</li>
<li><strong>A plain-text configuration</strong>, readable, commentable, versionable, where every value has a provenance; and a local socket through which everything can be driven from a script.</li>
</ul>
<p>What it does not bring: it does not raise the hotend&rsquo;s flow ceiling, it does not replace wrong mechanics, and it needs a computer next to the printer.</p>
<p>The host, for now, is my laptop plugged in over USB; a Raspberry Pi will take over. No web interface yet: Klipper exposes a local socket you send commands to, and the AI wrote in an hour what was needed to avoid typing G-code by hand: a client to send a command and read the answer, a print-monitoring script, and above all <code>calib.py</code>, a keyboard driver where one key is one gesture — <code>h</code> to home, <code>s</code> to start the delta calibration, <code>m</code> for the bed grid, <code>d</code>/<code>u</code> to lower or raise the nozzle by 0.1 mm, <code>[</code>/<code>]</code> to adjust the first layer while it prints.</p>
<p>The stance from the start is the same as on the Bambu: <strong>we measure, we don&rsquo;t guess</strong>. Every value written into the configuration comes from a measurement on the machine, and every correction is followed by a control part.</p>
<details>
<summary>Going deeper: what a delta firmware does, and what each setting corrects</summary>
<p>On a delta, the nozzle position is computed from seven and a half numbers: the <strong>arm length</strong> (<code>arm_length</code>), the <strong>delta radius</strong> (<code>delta_radius</code>, the horizontal distance between a column&rsquo;s axis and the arm pivot on the carriage, minus the one on the effector side), the <strong>angle</strong> of each column (nominally 210°, 330° and 90°), and the <strong>endstop height</strong> of each column (<code>position_endstop</code>, where the carriage triggers its limit switch). When one is wrong, the error on the bed has a recognisable shape:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Wrong setting</th>
<th>Shape of the error on the bed</th>
</tr>
</thead>
<tbody>
<tr>
<td>one endstop height</td>
<td>a <strong>tilted plane</strong> towards that column</td>
</tr>
<tr>
<td>delta radius</td>
<td>a centred, symmetric <strong>bowl</strong> or <strong>dome</strong></td>
</tr>
<tr>
<td>arm length</td>
<td>a bowl/dome too, plus a <strong>scale error</strong> in XY (parts too big or too small)</td>
</tr>
<tr>
<td>a column angle</td>
<td>a <strong>clover</strong> distortion, three lobes, and twisted parts</td>
</tr>
</tbody>
</table></div>
<p>Klipper measures them all with <code>DELTA_CALIBRATE</code>: you bring the nozzle down onto seven points of the bed (with a probe, or with paper), and it adjusts the parameters so that those seven points lie in a plane. The <strong>extended</strong> version (<code>DELTA_ANALYZE</code>) adds measurements of a printed part to adjust the arms one by one. On top of this geometry, the <strong>bed mesh</strong> corrects what remains — the flatness defects of the glass itself — by interpolating between measured points. Order matters: the grid can only properly correct what is <em>local</em>; a geometry error is not local, and a grid trying to compensate for it never quite gets there. That is the heart of this post.</p>
</details>
<h2 id="mechanics-first-the-belts">Mechanics first: the belts</h2>
<p>Before touching the firmware, a quick audit of what moves. The belts on this machine have a history: a first set destroyed by over-tensioning (&ldquo;I had read you should never tension them, and that&rsquo;s what happened to me&rdquo;), back when &ldquo;no setting had any effect&rdquo;. That sentence, in hindsight, was the signature of a mechanical problem and not a slicer one, and it is what justifies starting there.</p>
<p>The AI first proposed the method you read everywhere: pluck the belt, measure the note with a guitar app, aim for a frequency. Three rounds later, I cut it short: &ldquo;<em>it&rsquo;s not a string, if I tension it enough to ring it&rsquo;ll break everything</em>&rdquo;. She checked, and I was right: on this machine the free span is 65 cm, the target frequency would be under 30 Hz, where apps drop an octave (I was reading 70 Hz without ever landing on the same value twice). Above all, a Hackaday article reminds us that print quality barely changes with frankly loose belts, as long as the axis does not lag the motor; whereas over-tensioning breaks motor shafts (a NEMA 17 takes 28 N of radial load). There is no trade-off to find: <strong>no benefit to tightening, a risk to tightening.</strong></p>
<p>The method kept is the one I have used for years: slacken frankly, then re-tension just to the threshold where the belt starts to vibrate softly instead of flapping. You approach from below, so over-tensioning is impossible; the threshold is exactly the lower limit of no-play; the same gesture on the three columns equalises by construction. The six pulleys spun freely. Audit closed. <em>We will see later that this re-tensioning, done at the wrong moment, still cost dearly.</em></p>
<h2 id="what-the-machine-had-inside">What the machine had inside</h2>
<p>Before flashing, the original firmware&rsquo;s memory had to be read: the values it used are the starting point of the Klipper configuration, and once flashed, they can no longer be read. First surprise, plugging in the USB: <strong>it is not Marlin</strong>. Everything you read about the QQ-S assumes Marlin, and the AI had prepared the Marlin commands (<code>M503</code> to read the configuration, <code>M665</code>/<code>M666</code> for the delta geometry). The machine&rsquo;s answer: <code>Unknown command</code>. The firmware announces itself as <code>FIRMWARE_NAME:Robin</code> and it is <strong>Repetier</strong>, which dumps its memory with <code>M205</code>. First lesson, not the last: verify before asserting.</p>
<p>The dump — 970 m of filament and 233 machine hours — delivered two things.</p>
<p>First the real values: arms 278.6 mm (the reference Klipper config says 280), delta radius <strong>140.8</strong> (the reference says 130 — ten millimetres apart, enough for a monstrous dome had we taken the reference), height 376.03, 100 steps/mm on the columns and <strong>367 steps/mm on the extruder</strong> (remember that figure).</p>
<p>Then an observation that already explained a lot: <strong>every per-column correction term was at zero</strong>. Delta radius A/B/C, diagonal correction A/B/C, all zero. This machine had <strong>never</strong> been finely calibrated as a delta — or had been at the factory and lost it in a firmware update, which resets the EEPROM: that is my hypothesis, and the EEPROM keeps no history to settle it. Either way, its geometry errors, whatever they were, had been intact for years, and the extruder steps probably came from the same reset.</p>
<details>
<summary>Going deeper: reading a Repetier EEPROM, and what carries over to Klipper</summary>
<p><code>M205</code> returns one line per value, in the format <code>EPR:&lt;type&gt; &lt;position&gt; &lt;value&gt; &lt;label&gt;</code>. The firmware replies <code>ok</code> <strong>before</strong> the answer, not after: a script that reads &ldquo;until ok&rdquo; misses everything; you have to read until silence. What carries over to Klipper: <code>Diagonal rod length</code> → <code>arm_length</code>, <code>Horizontal rod radius at 0,0</code> → <code>delta_radius</code>, <code>Z max length</code> → <code>position_endstop</code> of the three columns, <code>Max printable radius</code> → <code>print_radius</code>, and steps/mm → <code>rotation_distance</code> (for a GT2 belt on a 16-tooth pulley, 32 mm per turn; for the extruder, <code>rotation_distance = steps_per_turn / steps_per_mm</code>, i.e. 3200 / 367 = 8.719). PIDs do not carry over (different scales), they are redone. The per-column endstop offsets (X 0, Y 7, Z 83 steps in the EEPROM) were not carried over: Repetier&rsquo;s sign convention is not certain, and <code>DELTA_CALIBRATE</code> re-derives them anyway.</p>
<p>The full dump is in the files at the end of the post (<code>README-flsun.md</code> points to the repository&rsquo;s <code>eeprom-repetier.txt</code>).</p>
</details>
<h2 id="klipper-and-motors-with-no-strength-left">Klipper, and motors with no strength left</h2>
<p>The flash itself is uneventful: Klipper compiled for the STM32F103 of the <strong>MKS Robin Mini</strong> board, with a 28 KB bootloader, an 8 MHz crystal and the USART3 serial port; the binary passed through Klipper&rsquo;s small <code>update_mks_robin.py</code> script that scrambles it into the format the MKS bootloader expects; copied to a 4 GB SD card in FAT32, printer off when inserting. A beep, &ldquo;complete&rdquo; in green, and Klipper answers <code>Klipper state: Ready</code>.</p>
<p>And then the first homing failed. <code>No trigger on stepper_b after full movement</code>: a carriage did not reach its switch. The second homing too, on another column. The motors growled, climbed at 5 mm/s, stalled at 25, and came back down &ldquo;limply&rdquo;.</p>
<p>What followed is a lesson rather than a story. The AI ran through hypotheses — step pulse duration, microstepping mode, belts, a current potentiometer that does not exist on this board —, each tested, each dismissed, and each test made the conversation longer without adding information. It is a behaviour worth knowing when working with an AI: the more its context window fills with failed attempts, the more it slides towards the next attempt instead of returning to the facts. What stopped it fits in one sentence, a hard constraint: &ldquo;before the flash, everything worked perfectly&rdquo;. The day before, under Repetier, the same mechanics worked; the hardware was out of the question by construction, and the only valid question became: <em>what did the old firmware do that the new one does not?</em></p>
<p>She then did what she should have done first: read this board&rsquo;s pin file in the Marlin sources, <code>pins_MKS_ROBIN_MINI.h</code>, and look for <strong>everything</strong> the old firmware initialised at boot. And there:</p>
<pre><code>MOTOR_CURRENT_PWM_XY_PIN  PA6
MOTOR_CURRENT_PWM_Z_PIN   PA7
MOTOR_CURRENT_PWM_E_PIN   PB0
MOTOR_CURRENT_PWM_RANGE   1500
DEFAULT_PWM_MOTOR_CURRENT { 800, 800, 800 }
</code></pre>
<p>On the Robin Mini, the reference voltage that sets the <strong>motor current</strong> is not set by a potentiometer: it is <strong>generated by the microcontroller</strong>, as a PWM signal on three pins. Repetier set them at boot. Klipper left them floating — near-zero reference, tiny torque, drift over the minutes (&ldquo;it moves better than at the start&rdquo;). And <strong>the official Klipper configuration for this printer, <code>printer-flsun-qqs-2020.cfg</code>, does not contain these lines</strong> (as I write). Anyone following that config on a Robin Mini has under-powered motors.</p>
<p>The fix is three configuration blocks: one hardware PWM output per pin, 800 on a scale of 1500, at 1 kHz, Marlin&rsquo;s default value. Immediate homing, three columns triggered, normal speed. &ldquo;<em>Perfect movement!</em>&ldquo;</p>
<details>
<summary>Going deeper: the three blocks, and the methodological lesson</summary>
<pre><code>[output_pin motor_current_ab]
pin: PA6
pwm: True
hardware_pwm: True
cycle_time: 0.001
scale: 1500
value: 800

[output_pin motor_current_c]
pin: PA7
# same

[output_pin motor_current_e]
pin: PB0
# same
</code></pre>
<p>800/1500 corresponds to roughly 1.28 V of reference, about 0.9 A RMS on the board&rsquo;s TMC2208 drivers. Adjust in steps of 50, not beyond ~1000 without cooling. The three pins are on the same timer (TIM3); <code>hardware_pwm</code> is accepted without error.</p>
<p>The lesson goes beyond this board: when &ldquo;it worked before&rdquo; under another firmware, you must inventory <strong>everything</strong> the old firmware initialised — current, microstepping, driver modes, not just STEP/DIR/ENABLE. The AI&rsquo;s mistake was not being wrong, but reasoning (&ldquo;the current comes from a potentiometer, so it hasn&rsquo;t changed&rdquo;) instead of checking how this particular board produces its reference.</p>
</details>
<h2 id="calibrating-a-delta-without-a-probe">Calibrating a delta without a probe</h2>
<p>The machine&rsquo;s probe was not around. That does not matter: Klipper&rsquo;s documentation recommends <strong>manual</strong> calibration on a delta anyway, because a probe mounted beside the nozzle introduces its own error when the effector tilts. The method is the sheet of paper: <code>DELTA_CALIBRATE METHOD=manual</code> sends the nozzle 2 mm above seven points of the bed, one in the centre and six in a circle; at each point you lower the nozzle in 1 mm steps, then 0.1, then by bisection, until a 0.1 mm sheet of paper just drags between nozzle and glass; you accept, the nozzle goes to the next point. Seven points, a quarter of an hour. Klipper then computes the parameters that put those seven points in a plane.</p>
<p><img alt="Klipper's calibrate_size.stl part on the FLSun QQ-S bed: ring, six spokes, seven hexagonal pillars, letters A, B, C towards the columns" src="https://pf.olibrio.fr/images/flsun/04-calibrate-size.jpg" /></p>
<p><em>The <code>calibrate_size.stl</code> part shipped with Klipper, for the extended calibration of the arms. It will be printed but not used: we will see why.</em></p>
<p>The first result, on the afternoon of 4 September: delta radius 141.18 (the EEPROM said 140.8), and above all <strong>column A at 211.02° and column B at 330.45°</strong>, instead of the nominal 210° and 330°. One degree on A. That is huge for a delta, and it was the defect &ldquo;no setting corrected&rdquo;: a wrong column angle twists parts and warps the bed into a clover, and no slicer setting, no bed grid, catches up with that.</p>
<p>Right after, the <strong>bed mesh</strong>: <code>BED_MESH_CALIBRATE METHOD=manual</code>, same paper gesture on 13 points, to correct what the glass has that isn&rsquo;t flat. Range 0.51 mm, dominated by the two edges of the Y axis. Saved, loaded at the start of every print. Cold calibration done.</p>
<p><em>Note this detail: the grid was measured cold, and I re-tensioned the belts between the delta calibration and its verification. We will come back to it.</em></p>
<h2 id="the-extruder-was-lying-by-115">The extruder was lying by 11.5%</h2>
<p>The simplest test in the world, and I had never done it in six years: a marker line on the filament 120 mm from the extruder inlet, ask the machine to feed 100, measure what is left. Expected: 20 mm. Measured: <strong>31.5 mm</strong>. The machine had pushed 88.5 mm for 100 requested.</p>
<p>The EEPROM&rsquo;s 367 steps/mm, faithfully carried into Klipper (<code>rotation_distance</code> 8.719), were wrong by 11.5%. Corrected to 7.716, cross-checked on a second test: 20.0 mm left, &ldquo;<em>perfect!</em>&rdquo;.</p>
<p>Measure what that means: <strong>every layer this machine ever laid down was missing a tenth of its material</strong>. Weak walls, open seams, uneven tops, poor layer adhesion — everything I blamed on &ldquo;the delta&rdquo; or &ldquo;the PETG&rdquo;. And no slicer setting compensates for an error you don&rsquo;t know about: you can push flow to 110% at random, you don&rsquo;t know why, and it doesn&rsquo;t hold from one filament to the next.</p>
<p>With the column angles and the motor current, that makes <strong>three factory defects</strong> — from the factory or from a firmware update that reset everything, the machine will not say which — never seen.</p>
<h2 id="pid-wet-petg-and-the-first-print">PID, wet PETG and the first print</h2>
<p>The temperature controls (PID) recalibrate in ten minutes, but the nozzle&rsquo;s tripped Klipper&rsquo;s thermal protection: <code>heater extruder not heating at expected rate</code>. The curve showed a perfectly healthy heat-up (27 → 234 °C in 95 s), then an unusual inertia between the block and its thermistor that makes the protection believe heating has stopped. No disabling: a widened window in <code>[verify_heater extruder]</code>, documented in the config.</p>
<p>Then the first part, in PETG, the one that had been living on the machine for ages. The cube came out… like this:</p>
<p><img alt="20 mm cube in black PETG printed on the FLSun QQ-S, foamy irregular walls and dangling strands: wet filament" src="https://pf.olibrio.fr/images/flsun/01-cube-petg-humide.jpg" /></p>
<p><em>The first cube, in PETG. It crackled in the nozzle while printing: the spool was saturated with water. The walls are foam.</em></p>
<p>That is not the printer: it is the filament. PETG is hygroscopic, and a spool left in the open for years crackles in the nozzle (the water flashes to steam) and comes out foamy. A good part of this machine&rsquo;s &ldquo;disappointing reliability&rdquo; was that. A new spool of PLA for the whole calibration; the PETG will go through the filament dryer.</p>
<p>In PLA, Klipper&rsquo;s <code>square.stl</code> (a frame with a one-millimetre wall, five millimetres high, six minutes): wall 1.00 mm, height 5.00. And the first control cube: <strong>Z 20.00 · X 20.40 · Y 20.45</strong>. The height is perfect — the columns are good. The 2% excess in XY, we will settle at the end.</p>
<p><img alt="Klipper's square.stl first-layer test: two nested frames in white PLA on the delta's black bed" src="https://pf.olibrio.fr/images/flsun/03-square.jpg" /></p>
<p><em><code>square.stl</code>: Klipper&rsquo;s first-layer and flow test. Wall 1.00 mm, spot on.</em></p>
<p><img alt="Close-up of a frame's foot: a thin lip overflows at the base of the wall" src="https://pf.olibrio.fr/images/flsun/02-square-levre.jpg" /></p>
<p><em>The foot of a frame, very close: the small lip overflowing at the base says the first layer is a little too squished. This is the test used to set the first-layer offset, in 0.05 mm steps, while printing.</em></p>
<h2 id="pressure-advance">Pressure advance</h2>
<p>Last setting before tackling the bed: <strong>pressure advance</strong>. On a bowden machine (the extruder motor is on the frame, a long tube brings the filament to the nozzle), the pressure in the tube takes time to rise and fall; without compensation, corners bulge and line ends ooze. Klipper compensates by pushing a little more before accelerations and pulling back before decelerations, with a single parameter. You measure it with a test tower where the parameter grows with height (<code>TUNING_TOWER</code>), and look by eye for the height where corners are sharpest.</p>
<p><img alt="Corner of Klipper's square_tower pressure advance tower in white PLA, grazing view: the edge changes with height" src="https://pf.olibrio.fr/images/flsun/05-pa-tower.jpg" /></p>
<p><em>The <code>square_tower.stl</code> tower: at the bottom, not enough compensation, corners bulge; at the top, too much, they hollow. The best corner is at 19 mm.</em></p>
<p>Best corner at 19 mm, 0.020 per millimetre: <strong>pressure advance 0.38</strong>, in the expected range for a bowden. Saved.</p>
<h2 id="the-spiral-or-how-to-see-your-first-layer">The spiral, or how to see your first layer</h2>
<p>To judge levelling, I needed better than the sheet of paper: I remembered a spiral object you print to see the first layer at a glance. Rather than look for a file sliced for another machine, the AI generated the G-code directly: a single continuous line, an Archimedean spiral, from the centre out to a 120 mm radius, one turn every 2.5 mm, a single 0.2 layer. Twelve minutes. Where the nozzle is too close, the bead is squashed, flat, translucent; where it is too far, it is round, narrow, poorly stuck. On a round bed, it is the most readable test there is.</p>
<p><img alt="Levelling spiral on the FLSun QQ-S round bed: three bright sectors alternating with three dark ones, a clover pattern" src="https://pf.olibrio.fr/images/flsun/06-spirale-1-trefle.jpg" /></p>
<p><em>The first spiral. Three bright lobes 120° apart: the signature of a delta geometry, not of a warped bed.</em></p>
<p>The first spiral showed a <strong>three-lobed clover</strong>. A warped glass does not make a clover; a badly calibrated delta does. And yet the calibration had just been done. The right conclusion would have been: <em>the geometry isn&rsquo;t right yet, redo the calibration</em>. We took the other path.</p>
<h2 id="false-trail">The false trail: two days correcting the grid</h2>
<p>My request was reasonable: &ldquo;<em>a print with a predetermined thickness would be more relevant than the paper test</em>&rdquo;. The AI built the method: print a one-layer square on each of the 13 grid points, measure their thickness, and correct each grid point by its deviation from the mean. A square thicker than the others = the nozzle was higher there = the bed is lower there than the grid believes = lower the point. She wrote the script (<code>fix-mesh.py</code>) that homes, places the nozzle 100 mm above each square to identify it (&ldquo;<em>how do I tell which is which?</em>&rdquo; — the logo is not a reference), records the measurements, rewrites the grid. My micrometer being graduated in inches, you enter thousandths (<code>13t</code>).</p>
<p><img alt="Thirteen one-layer PLA squares printed on the thirteen Klipper bed mesh points, round delta bed" src="https://pf.olibrio.fr/images/flsun/07-carres-13.jpg" /></p>
<p><em>Pass 1: thirteen 20 mm squares, one layer, one per grid point.</em></p>
<p><img alt="The sleeve of an old micrometer: graduations 0 to 7, one mark every 25 thousandths of an inch" src="https://pf.olibrio.fr/images/flsun/09-micrometre-pouces.jpg" /></p>
<p><em>The micrometer, graduated in inches: one sleeve mark is 25 thousandths, the thimble gives the units. 13 thou = 0.33 mm.</em></p>
<p><img alt="A rusty Lufkin Rule Co. micrometer from Saginaw, Michigan, model No. 1941, its spindle closed on a fragment of translucent PLA first layer" src="https://pf.olibrio.fr/images/flsun/09b-micrometre-lufkin.jpg" /></p>
<p><em>The instrument itself: a Lufkin Rule Co. from Saginaw, Michigan, No. 1941. Older than me, and still measuring to a thousandth of an inch. Between its anvils, one layer of PLA.</em></p>
<p>It worked. Pass 1: thickness range 0.30 mm. Pass 2: <strong>0.084 mm</strong>. The next spiral was clearly better, the clover fading. Then we moved to a finer grid, 7×7 (29 points, 40 mm pitch instead of 55), because the remaining defects seemed to fall <em>between</em> the points. Pass 3, pass 4, reduced gain to avoid oscillating. And on every spiral, <strong>the same zones came back</strong>, attenuated but in the same place.</p>
<p><img alt="Spiral 3 annotated: five zones circled in red and one in blue at the front-right edge" src="https://pf.olibrio.fr/images/flsun/10-spirale-3-annotee.jpg" /></p>
<p><em>Spiral 3, annotated by me: red = too close (scraped), blue = too far (not stuck). Five patches, no clear clover any more.</em></p>
<p><img alt="Spiral 4 annotated: two red zones, one blue at the same edge" src="https://pf.olibrio.fr/images/flsun/11-spirale-4-annotee.jpg" /></p>
<p><em>Spiral 4, after the 7×7 grid: two red zones instead of five, the blue still at the same corner.</em></p>
<p><img alt="Twenty-nine one-layer squares on the FLSun QQ-S 7×7 bed mesh grid, two squares on the left torn" src="https://pf.olibrio.fr/images/flsun/12-carres-29.jpg" /></p>
<p><em>7×7 grid, 29 squares of 10 mm. Two squares on the left are torn: too close. The micrometer, though, measured them &ldquo;thick&rdquo;.</em></p>
<p>That is when I said what had to be said: &ldquo;<em>to me the error is very repeatable on the last three spirals. We have gaps in the same zones and the corrections have improved things but they don&rsquo;t really fix the root of the problem.</em>&rdquo; Three correction passes should have been enough. Something independent of the setting was resisting.</p>
<p><img alt="Spiral 5 annotated: two red zones, three pink, one blue" src="https://pf.olibrio.fr/images/flsun/13-spirale-5-annotee.jpg" /></p>
<p><em>Spiral 5: red, almost no filament; pink, slight lack; blue, not stuck. The same zones, again.</em></p>
<p>Two macro shots settled it. In the blue zone, the bead is round, narrow, laid down without being pressed, and the transition is gradual over 25 mm towards the edge: the nozzle moves away steadily going outwards. In the red zone, the beads are <strong>wide, flat, translucent, squashed to a film</strong> — you can see the bed&rsquo;s dots through them. Too close, really.</p>
<p><img alt="Macro of the blue zone: round, narrow, wavy beads with small knots" src="https://pf.olibrio.fr/images/flsun/14-macro-trop-loin.jpg" /></p>
<p><em>Blue zone: round bead, not pressed, wavy and knotted where it caught. Too far.</em></p>
<p><img alt="Macro of the transition into the red zone: wide, flat, translucent beads, almost a film" src="https://pf.olibrio.fr/images/flsun/15-macro-trop-pres.jpg" /></p>
<p><em>Red zone: squashed, translucent beads, the bed shows through. Too close, no argument.</em></p>
<p>And here is the contradiction: in those red zones, the squares measured a <em>normal</em> thickness. The explanation, once you see it: <strong>a square printed too close grows a raised lip around its edge</strong>, and the micrometer&rsquo;s anvils, six millimetres across, land on that lip. It reads &ldquo;thick&rdquo; exactly where it is squashed. The squares method was biased in precisely the direction that prevented convergence. Two of the AI&rsquo;s conclusions built on those measurements (&ldquo;the nozzle is 0.4 mm from the glass on average&rdquo;, &ldquo;the global zero must come down&rdquo;) fell with it.</p>
<p>The root question remained: why such repeatable zones? My lead was the belts. The AI wrote a repeatability test (key <code>R</code>: same point, five approaches along different paths, sheet of paper): <strong>repeatable to 0.1 mm</strong> whatever the path. Belts and play out of the question. But that test gave something else: at those points, the paper dragged at Z = +0.2 and <strong>+0.6 mm</strong> — the machine believed the bed was half a millimetre lower than it is. And the grid itself carried a 0.65 mm front-to-back slope that nothing justifies on a calibrated delta.</p>
<p>The cause was in day one&rsquo;s sequence. The delta calibration had been done <strong>cold</strong>; <strong>between</strong> that calibration and its verification, I had re-tensioned the belts; the verification had shown an offset, and the AI had applied <strong>+0.4 mm uniformly</strong> to the three endstops. But the re-tensioning had shifted the three columns <em>unequally</em>. The residue, a tilted slope, went into the grid, and a delta grid never makes a slope flat: it leaves lobes, which four passes of micrometer squares chased without catching.</p>
<details>
<summary>Going deeper: why a grid does not compensate a geometry error</summary>
<p>An endstop error on one column does not produce an exact tilted plane but a slightly curved surface, because the effector&rsquo;s tilt changes with its position; an angle error produces a clover. The grid samples that surface at 13 or 29 points and interpolates (here bicubic) between them: it captures the trend, not the fine curvature, and leaves a residue wherever the true surface departs from the interpolation. The bigger the geometry error, the bigger the residue. That is why Klipper asks for geometry first, grid second, and why &ldquo;redo the grid&rdquo; never replaces &ldquo;redo the delta calibration&rdquo;. And that is why you must calibrate in the state you print in: a bed at 60 °C is not exactly where it is at 20 °C (here, +0.36 mm at the centre), and the difference is not necessarily uniform.</p>
</details>
<h2 id="the-arrival-redo-everything-hot-with-paper">The arrival: redo everything hot, with paper</h2>
<p>Bed at 60 °C, nozzle cold, grid cleared. Manual <code>DELTA_CALIBRATE</code>, seven points with paper, fifteen minutes. The verdict is in the endstops:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Column</th>
<th>Endstop before</th>
<th>Endstop after</th>
<th>Difference</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td>376.252</td>
<td>376.075</td>
<td>−0.18</td>
</tr>
<tr>
<td>B</td>
<td>376.533</td>
<td>375.970</td>
<td><strong>−0.56</strong></td>
</tr>
<tr>
<td>C</td>
<td>377.204</td>
<td>376.859</td>
<td>−0.35</td>
</tr>
</tbody>
</table></div>
<p>The angles barely moved (A 211.02 → 210.93, B 330.45 → 330.33), the radius neither (141.18 → 141.29). The geometry held. What was wrong were <strong>the endstops, unequally</strong>: 0.38 mm between A and B. Exactly the slope the grid carried.</p>
<p>Save, home, then the paper grid on the 29 points, half an hour. Centre flat to ±0.1 mm over an 80 mm radius; the front edge dips 0.6 mm at 120 mm from the centre — that is the glass itself, or the delta at the end of its reach, and this time it is measured directly and the grid knows it. Then the spiral:</p>
<p><img alt="Uniform levelling spiral on the FLSun QQ-S after hot delta recalibration under Klipper: regular turns from edge to edge" src="https://pf.olibrio.fr/images/flsun/16-spirale-6-uniforme.jpg" /></p>
<p><em>Spiral 6, the first with a consistent geometry and grid, both measured hot. Uniform from edge to edge, front edge included.</em></p>
<p><img alt="Macro of the sixth spiral: regular beads, same width from one turn to the next" src="https://pf.olibrio.fr/images/flsun/17-macro-spirale-6.jpg" /></p>
<p><em>The bead, under the loupe: regular, same width from turn to turn, stuck everywhere.</em></p>
<p>Six spirals, and the good one was the one where we stopped correcting and recalibrated, in thirty-five minutes, with a single method.</p>
<p>One last trap remained, from the same family: the first-layer offset, +0.07 mm, had been set on day one on the <em>cold</em> zero. On the new, hot zero, the control cube did not stick. <strong>A new zero means re-setting the first-layer offset, never carrying the old one over.</strong> Test frame, live adjustment in 0.05 steps: −0.10 barely sticks, −0.20 sticks with a 0.3 mm lip at the foot; saved at <strong>−0.15</strong>.</p>
<p><img alt="First layer of a PLA cube detached from the bed: curled fragment with strands, first-layer offset too high" src="https://pf.olibrio.fr/images/flsun/18-cube-pas-colle.jpg" /></p>
<p><em>The control cube restarted with the old first-layer offset: it did not stick. The zero had changed, the offset had not.</em></p>
<p>And the cube, at last: <strong>20.3 × 20.4 × 20.00</strong>, a 0.1 mm lip per side. On day one it was 20.40 × 20.45 × 20.00. The XY excess did not move with the geometry, because it is not geometry: the seven-pillar part, measured with the caliper, gave a pillar spacing of 65.0 ± 0.4 mm on a nominal 65, i.e. a scale correct to 0.5%; an arm error would have given +1.3 mm there. What remains is a <strong>constant excess</strong> of bead width — bulging corners, a 1.07 wall for 1.00 — and that is settled in slicing as on any machine: extrusion multiplier 0.95, XY compensation −0.1 mm, elephant-foot compensation 0.1 mm, in a production profile separate from the raw test profile. The long, delicate extended arm calibration was not necessary. Control cube with that profile: <strong>20.0 × 20.1 × 20.0</strong>.</p>
<h2 id="what-i-take-away">What I take away</h2>
<p><strong>Calibrate in the state you print in.</strong> Hot. A machine calibrated cold describes a bed that no longer exists when the PLA arrives.</p>
<p><strong>One measurement method, from start to finish.</strong> Paper for the geometry, paper for the grid, the test frame for the first layer. Every change of method introduced a bias nobody saw.</p>
<p><strong>Geometry first, grid second, never the reverse.</strong> A grid that fails to converge in two passes is not short of points: it is compensating an error that is not its own. And any belt re-tensioning puts the geometry back in play — redo <code>s</code> then <code>m</code>, no shortcut.</p>
<p><strong>When two measurements disagree, go and look.</strong> The loupe on the bead settled in one minute what four micrometer passes had not. A measurement is not a truth, it is an instrument with blind spots.</p>
<p><strong>The AI over-engineers when it misunderstands.</strong> The grid-correction script with micrometer squares was clever, well made, persistent, with resume and corrections — and it was optimising a bad idea. I am the one who said &ldquo;it&rsquo;s repeatable, it&rsquo;s not the setting&rdquo;, and the macro shot is what settled it. On the other hand, she found in one reading what six years of forums had not: the PWM motor current, the extruder off by 11.5%, the calibration never done.</p>
<p><strong>&ldquo;It worked before&rdquo; is data.</strong> Twice in this story, my judgement about my machine was worth more than a method read elsewhere: on belt tension, on homing. And twice, the AI ended up verifying it and proving me right, with sources. That is the right split: she reads fast, she does not measure in my place.</p>
<h2 id="where-things-stand">Where things stand</h2>
<div class="status">
<p><strong>Status on 6 September 2026: machine calibration complete.</strong> Geometry, zero and grid measured hot with paper; first layer at −0.15; extruder at 7.716; pressure advance 0.38; PIDs redone; motor current set. Uniform spiral; control cube at 20.0 × 20.1 × 20.0 with the production profile. Remaining, in order of interest: resonance compensation (input shaper) with Klipper&rsquo;s test tower, a dual-drive extruder in stock, and the Raspberry Pi as host to make the printer standalone. The PETG is drying.</p>
</div>
<h2 id="to-reproduce">To reproduce</h2>
<p><strong>Hardware</strong>: a 2020 FLSun QQ-S (MKS Robin Mini board) — the approach applies to any delta, the pins do not; a Linux computer with a USB port; an SD card ≤ 8 GB in FAT32; a sheet of 80 g paper; a caliper; a <strong>dry</strong> spool of PLA. No probe, no accelerometer.</p>
<p><strong>Before flashing</strong>, read the original firmware&rsquo;s memory (on Repetier: <code>M205</code>; on Marlin: <code>M503</code>) and keep the dump. Note in particular the arm length, the delta radius, the height, and the extruder&rsquo;s steps/mm.</p>
<p><strong>Firmware</strong>: Klipper, <code>make menuconfig</code> → STM32F103, 28 KiB bootloader, 8 MHz crystal, USART3 (PB11/PB10), 250000 baud; then <code>scripts/update_mks_robin.py out/klipper.bin Robin_mini.bin</code>; the file at the root of the SD card, printer off when inserting, power on, wait for &ldquo;complete&rdquo;. The bootloader renames the file to <code>.CUR</code>. The <a href="https://pf.olibrio.fr/assets/flsun/build-firmware.sh"><code>build-firmware.sh</code></a> script does all of it.</p>
<p><strong>Configuration</strong>: <a href="https://pf.olibrio.fr/assets/flsun/printer.cfg"><code>printer.cfg</code></a>, with its real values and comments. The three <code>[output_pin motor_current_*]</code> blocks are <strong>indispensable</strong> on a Robin Mini. The <code>SAVE_CONFIG</code> block at the end of the file holds the calibration results; to start from scratch, delete it and put the EEPROM values back into <code>[stepper_a/b/c]</code> and <code>[printer]</code>.</p>
<p><strong>Tools</strong>: <a href="https://pf.olibrio.fr/assets/flsun/start-klipper.sh"><code>start-klipper.sh</code></a> (start the host), <a href="https://pf.olibrio.fr/assets/flsun/send-gcode.sh"><code>send-gcode.sh</code></a> and <a href="https://pf.olibrio.fr/assets/flsun/klippy-client.py"><code>klippy-client.py</code></a> (send a command), <a href="https://pf.olibrio.fr/assets/flsun/calib.py"><code>calib.py</code></a> (keyboard driver for calibrations), <a href="https://pf.olibrio.fr/assets/flsun/watch-print.py"><code>watch-print.py</code></a> (print monitoring), <a href="https://pf.olibrio.fr/assets/flsun/make-spiral.py"><code>make-spiral.py</code></a> (the spiral). The command-line PrusaSlicer profiles are in <a href="https://pf.olibrio.fr/assets/flsun/slicer/"><code>slicer/</code></a>: <code>qqs-pla.ini</code> raw for tests, <code>qqs-pla-prod.ini</code> for parts.</p>
<p><strong>In order, hot</strong> (bed at printing temperature, nozzle cold for the paper):</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Step</th>
<th>Command / key</th>
<th>Time</th>
<th>What you get</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. Homing</td>
<td><code>h</code></td>
<td>1 min</td>
<td>known position</td>
</tr>
<tr>
<td>2. Geometry</td>
<td><code>s</code> (manual DELTA_CALIBRATE), 7 points with paper, then <code>c c</code></td>
<td>15 min</td>
<td>radius, angles, endstops</td>
</tr>
<tr>
<td>3. Verification</td>
<td><code>v</code> (7 points at Z = 0.1)</td>
<td>5 min</td>
<td>±0.05 expected; otherwise redo 2</td>
</tr>
<tr>
<td>4. Grid</td>
<td><code>m</code> (manual BED_MESH_CALIBRATE), then <code>c c</code></td>
<td>20–30 min</td>
<td><code>default</code> profile, loaded by START_PRINT</td>
</tr>
<tr>
<td>5. Extruder</td>
<td>mark at 120 mm, <code>M83</code> <code>G1 E100 F60</code>, measure</td>
<td>5 min</td>
<td><code>rotation_distance</code> × extruded/100</td>
</tr>
<tr>
<td>6. PID</td>
<td><code>PID_CALIBRATE HEATER=extruder TARGET=210</code>, same for <code>heater_bed</code></td>
<td>15 min</td>
<td>gains, <code>SAVE_CONFIG</code></td>
</tr>
<tr>
<td>7. First layer</td>
<td><code>square.stl</code>, <code>[</code> <code>]</code> live, save in START_PRINT</td>
<td>7 min</td>
<td>offset</td>
</tr>
<tr>
<td>8. Pressure advance</td>
<td><code>square_tower.stl</code> + <code>TUNING_TOWER … START=0 FACTOR=.020</code></td>
<td>50 min</td>
<td>height of best corner × 0.020</td>
</tr>
<tr>
<td>9. Control</td>
<td>spiral, then 20 mm cube</td>
<td>12 + 19 min</td>
<td>uniform; 20.00 in Z</td>
</tr>
</tbody>
</table></div>
<p>Three rules, if you go for it: <strong>hot</strong>, <strong>a single measurement method</strong>, and <strong>any mechanical intervention (belts, arms, bed) sends you back to step 2</strong>. If a grid does not converge in two passes, do not add points: redo the geometry.</p>
<p><strong>Reading the spiral</strong>: red = too close (flat, translucent, scraped bead), blue = too far (round, poorly stuck bead). A three-lobed clover = geometry; local patches = bed or grid; an edge dipping gradually = the glass or the delta at the end of its reach. When in doubt, the loupe.</p>
<h2 id="frequently-asked-questions">Frequently asked questions</h2>
<h3 id="do-you-need-a-probe-to-calibrate-a-delta-under-klipper">Do you need a probe to calibrate a delta under Klipper?</h3>
<p>No. Klipper&rsquo;s documentation recommends manual paper calibration on deltas, because a probe offset from the nozzle introduces its own error when the effector tilts. Seven points for the geometry, thirteen to twenty-nine for the grid, half an hour in all.</p>
<h3 id="my-motors-growl-and-homing-fails-after-switching-to-klipper-on-an-mks-robin-mini-why">My motors growl and homing fails after switching to Klipper on an MKS Robin Mini. Why?</h3>
<p>Because the motor current is set by PWM from the microcontroller (pins PA6, PA7, PB0) and the official Klipper config for the QQ-S does not do it. Three <code>[output_pin]</code> blocks in hardware PWM, value 800 out of 1500, fix the problem. See <a href="#klipper-and-motors-with-no-strength-left">Klipper, and motors with no strength left</a>.</p>
<h3 id="is-my-flsun-qq-s-running-marlin">Is my FLSun QQ-S running Marlin?</h3>
<p>Not the 2020 QQ-S: it runs Repetier (<code>FIRMWARE_NAME:Robin</code>). <code>M503</code>, <code>M665</code>, <code>M666</code>, <code>M900</code> answer <code>Unknown command</code>; <code>M205</code> reads the memory. Marlin tutorials do not apply.</p>
<h3 id="my-bed-grid-does-not-converge-even-after-several-passes-do-i-need-more-points">My bed grid does not converge, even after several passes. Do I need more points?</h3>
<p>Almost never. A grid that does not converge is usually compensating a geometry error (unequal endstops, column angle) or a calibration done cold. Redo <code>DELTA_CALIBRATE</code> hot, then the grid, with the same measurement method.</p>
<h3 id="how-do-i-know-whether-my-extruder-pushes-the-right-amount">How do I know whether my extruder pushes the right amount?</h3>
<p>A mark on the filament 120 mm from the extruder inlet, <code>M83</code> then <code>G1 E100 F60</code>, measure what is left: 20 mm expected. Here 31.5 were left: 11.5% under-extrusion since forever. New <code>rotation_distance</code> = old × extruded / 100.</p>
<h3 id="is-the-micrometer-thickness-squares-method-any-good">Is the micrometer thickness-squares method any good?</h3>
<p>It is biased where the nozzle is too close: the squashed square grows a raised lip around its edge, and the micrometer reads it &ldquo;thick&rdquo;. If you insist, 20 mm squares measured at the centre, anvils away from the edge. The spiral and the paper are more reliable.</p>
<h3 id="should-a-deltas-belts-be-tensioned-to-a-precise-frequency">Should a delta&rsquo;s belts be tensioned to a precise frequency?</h3>
<p>No. The frequency depends on the span length, the mass and the composition of the belt; the same note does not mean the same tension from one machine to another, and on a QQ-S the span is too long to measure. Slacken, then re-tension to the threshold where the belt vibrates softly instead of flapping, on the three columns. And redo the delta calibration afterwards.</p>
<h3 id="the-cube-is-203-instead-of-2000-in-xy-should-i-calibrate-the-arms">The cube is 20.3 instead of 20.00 in XY: should I calibrate the arms?</h3>
<p>Not if the excess is constant. Check on a larger part (Klipper&rsquo;s seven-pillar part, 65 mm spacing): if the scale is right, the excess comes from bead width and is corrected in the slicer (extrusion multiplier, XY compensation). Arm calibration corrects a scale error, not a constant excess.</p>
<h3 id="what-did-the-ai-do-exactly-and-what-did-the-human-do">What did the AI do exactly, and what did the human do?</h3>
<p>Claude read the EEPROM and the board&rsquo;s pin file, compiled and prepared the firmware, wrote the configuration and all the scripts, generated the spiral, ran the calibrations and wrote this post with me. She was wrong about the firmware (Marlin), about the cause of the homing failure (several hours), about belt tension, and about the squares method. I held the sheet of paper, the caliper, the micrometer and the camera; I read the spirals; I said &ldquo;it worked before&rdquo;, &ldquo;it&rsquo;s not a string&rdquo; and &ldquo;it&rsquo;s repeatable, it&rsquo;s not the setting&rdquo;. All three times, that was the right lead.</p>
<h2 id="short-glossary">Short glossary</h2>
<ul>
<li><strong>Delta</strong>: a printer where the nozzle is carried by three articulated arms on three vertical carriages; the position is computed, not measured axis by axis.</li>
<li><strong>Effector</strong>: the central part carrying the nozzle, at the end of the arms.</li>
<li><strong>Arms (<code>arm_length</code>)</strong>, <strong>delta radius (<code>delta_radius</code>)</strong>, <strong>column angle</strong>, <strong>endstop (<code>position_endstop</code>)</strong>: the geometry parameters; see the box &ldquo;what each setting corrects&rdquo;.</li>
<li><strong>Homing</strong>: sending the carriages up to their switches, to know their position.</li>
<li><strong>Bed mesh</strong>: a map of bed heights, measured at a few points, that the firmware interpolates to correct the nozzle continuously.</li>
<li><strong>Paper test</strong>: lowering the nozzle until a 0.1 mm sheet just drags; Z = 0 is then 0.1 mm above the glass.</li>
<li><strong>First-layer offset</strong>: a small Z shift applied at the start of each print to adjust how much the first layer is squished.</li>
<li><strong>Rotation distance</strong>: under Klipper, the distance travelled (or filament pushed) per motor turn; replaces steps/mm.</li>
<li><strong>Pressure advance</strong>: compensation for pressure in the bowden tube, which cleans up corners.</li>
<li><strong>PID</strong>: the temperature control; recalibrated with <code>PID_CALIBRATE</code>.</li>
<li><strong>Bowden</strong>: a layout where the extruder motor is on the frame and pushes the filament through a long tube to the nozzle; light for the effector, but elastic.</li>
<li><strong>Thou</strong>: a thousandth of an inch, 0.0254 mm. Old micrometers are graduated that way.</li>
<li><strong>Lip / elephant foot</strong>: a bulge at the base of a part, sign of an over-squished first layer.</li>
<li><strong>PWM</strong>: a square wave whose width is modulated; used here to manufacture a reference voltage for the motor current.</li>
<li><strong>EEPROM</strong>: the original firmware&rsquo;s memory where it keeps its settings.</li>
</ul>
<hr />
<h2 id="references">References and local copies</h2>
<p>So that this post stays useful once links have vanished, the project files are hosted here.</p>
<ul>
<li><strong>Klipper</strong> documentation: <a href="https://www.klipper3d.org/Delta_Calibrate.html">Delta Calibrate</a> · <a href="https://www.klipper3d.org/Bed_Mesh.html">Bed Mesh</a> · <a href="https://www.klipper3d.org/Bed_Level.html">Bed Level</a> · <a href="https://www.klipper3d.org/Manual_Level.html">Manual Level</a> · <a href="https://www.klipper3d.org/Pressure_Advance.html">Pressure Advance</a> · <a href="https://www.klipper3d.org/Resonance_Compensation.html">Resonance Compensation</a> · <a href="https://www.klipper3d.org/Config_Reference.html">Config Reference</a>. The test parts (<code>square.stl</code>, <code>square_tower.stl</code>, <code>calibrate_size.stl</code>, <code>ringing_tower.stl</code>) are in the repository&rsquo;s <a href="https://github.com/Klipper3d/klipper/tree/master/docs/prints"><code>docs/prints/</code></a>.</li>
<li><strong>Reference Klipper config</strong> for the QQ-S 2020 — <a href="https://github.com/Klipper3d/klipper/blob/master/config/printer-flsun-qqs-2020.cfg"><code>printer-flsun-qqs-2020.cfg</code></a> (without the motor current outputs, as of 6 September 2026).</li>
<li><strong>MKS Robin Mini pinout</strong> in Marlin — <a href="https://github.com/MarlinFirmware/Marlin/blob/bugfix-2.1.x/Marlin/src/pins/stm32f1/pins_MKS_ROBIN_MINI.h"><code>pins_MKS_ROBIN_MINI.h</code></a> (source of the PWM current pins).</li>
<li><strong>Hackaday</strong>, &ldquo;Don&rsquo;t Tune Your 3D Printer To Middle &lsquo;C&rsquo; After All&rdquo; — on belt tension by frequency (<a href="https://hackaday.com/?s=belt+tension+middle+C">site search</a>).</li>
<li><strong>PrusaSlicer 2.8.1</strong>, the last version with a Linux AppImage — <a href="https://github.com/prusa3d/PrusaSlicer/releases/tag/version_2.8.1">releases</a>. Run from the command line without a display (<code>env -u DISPLAY</code>); it adds its own heating commands unless it finds real <code>M104</code>/<code>M140</code> in the start G-code.</li>
<li><strong>The project files</strong>: <a href="https://pf.olibrio.fr/assets/flsun/printer.cfg"><code>printer.cfg</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/calib.py"><code>calib.py</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/klippy-client.py"><code>klippy-client.py</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/send-gcode.sh"><code>send-gcode.sh</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/start-klipper.sh"><code>start-klipper.sh</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/watch-print.py"><code>watch-print.py</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/build-firmware.sh"><code>build-firmware.sh</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/make-spiral.py"><code>make-spiral.py</code></a> · <a href="https://pf.olibrio.fr/assets/flsun/slicer/"><code>slicer/</code></a> profiles · <a href="https://pf.olibrio.fr/assets/flsun/README-flsun.md"><code>README-flsun.md</code></a> (the machine&rsquo;s state and the repository&rsquo;s user guide, in French).</li>
<li><strong>For the record</strong>, the false trail: <a href="https://pf.olibrio.fr/assets/flsun/pour-memoire/fix-mesh.py"><code>fix-mesh.py</code></a>, <a href="https://pf.olibrio.fr/assets/flsun/pour-memoire/mesh-to-7.py"><code>mesh-to-7.py</code></a>, <a href="https://pf.olibrio.fr/assets/flsun/pour-memoire/first_layer_29.stl"><code>first_layer_29.stl</code></a>, and <a href="https://pf.olibrio.fr/assets/flsun/pour-memoire/analyze-delta.py"><code>analyze-delta.py</code></a> for the extended arm calibration, should it ever become necessary.</li>
</ul>
<p><em>Photos and measurements: mine. Firmware, configuration, scripts and writing: with Claude. This post is also available as <a href="https://pf.olibrio.fr/en/posts/flsun-qqs-delta-klipper-calibration.md">raw Markdown</a>.</em></p>]]></content:encoded>
    </item>
    <item>
      <title>Neato error 4202 “battery discharged”: the day my vacuum decided it was dead</title>
      <link>https://pf.olibrio.fr/en/posts/erreur-4202-neato-obsolescence.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/erreur-4202-neato-obsolescence.html</guid>
      <pubDate>Sat, 29 Aug 2026 12:00:00 +0000</pubDate>
      <description>Error 4202 on a Neato D8, D9, D10 or D800: the battery isn’t necessarily dead. How I got the pack’s bq40z50 chip to talk using a two-euro ESP32, what it said, the twist that came the next day, and the new attempt.</description>
      <category>neato</category>
      <category>error 4202</category>
      <category>repair</category>
      <category>battery</category>
      <category>bq40z50</category>
      <category>esp32</category>
      <category>smbus</category>
      <category>obsolescence</category>
      <category>right to repair</category>
      <category>diy</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/neato/pack-pads-da-cl.jpg" alt="Neato error 4202 “battery discharged”: the day my vacuum decided it was dead"></p>
<div class="tldr">
<p><strong>Two minutes, if your Neato is blinking red with error 4202</strong></p>
<ul>
<li><strong>The symptom</strong>: red light, error 4202, “battery discharged”, the robot refuses to charge or to start. On the D8, D9, D10 and D800, the app (as long as it existed) added that the battery&rsquo;s <em>charge counter</em> had been exceeded.</li>
<li><strong>What it means</strong>: not necessarily that the battery is dead. It contains a chip that talks to the robot; it&rsquo;s <strong>what the chip says</strong> that triggers the error. Swapping the cells (the “batteries” inside) doesn&rsquo;t change what it says.</li>
<li><strong>What we found</strong> by getting it to talk with a two-euro microcontroller: no fault, no protection tripped, a counter at <strong>1,547 cycles</strong>, and a “don&rsquo;t charge me” that was nothing but its sleep mode.</li>
<li><strong>What we did</strong>: backed up its memory, reset the counter to zero. The robot worked again… for a day. <strong>Update, September 1</strong>: the real cause was elsewhere — a weaker salvaged cell tripping the over-voltage protection on every charge (240 times in three days). Charging voltage lowered, gauge put back into learning mode, under observation (see <a href="#ou-on-en-est">Where things stand</a>).</li>
<li><strong>Epilogue</strong>: the “dead” original cells, run through a capacity tester, still deliver 1,819 to 1,916 mAh out of a nominal 2,500. They had nothing to answer for either.</li>
<li><strong>Before buying a battery</strong>: read <a href="#pour-reproduire">How to reproduce it</a>. You need a soldering iron, three wires, an ESP32 and an evening. No €150 manufacturer programmer, no password to crack.</li>
</ul>
<p><em>This post is meant to be readable with no background in electronics: every technical term is explained the first time it appears, and a glossary recaps them at the end. The “Going deeper” boxes unfold for those who want the registers, the addresses and the bytes.</em></p>
</div>
<p><img alt="The Neato pack's circuit board, sleeve opened: the C+ and C− pads in the center, and on the right the two small pads marked DA and CL, with the yellow wires already soldered on" src="https://pf.olibrio.fr/images/neato/pack-pads-da-cl.jpg" /></p>
<p><em>The pack&rsquo;s board, once the sleeve is opened. The two small pads marked “DA” and “CL”, on the right, are all we need. The big ones in the center, C+ and C−, are the ones we don&rsquo;t touch.</em></p>
<h2 id="a-cloud-switched-off-then-a-button-that-dies">A cloud switched off, then a button that dies</h2>
<p>My robot vacuum is a Neato D800. A good robot: a spinning LIDAR (a small laser radar that rotates on top and measures the distance to the walls), a map of the house, no-go lines you draw in the app, €600 at the time. In 2023, Neato Robotics shut down. Vorwerk, the parent company, kept the servers running for a while — the “cloud”, meaning the remote computers without which the app, the maps and the scheduling don&rsquo;t work — then announced <a href="https://support.neatorobotics.com/support/solutions/articles/204000073686-announcement-6th-oct-2025">on October 6, 2025</a> (<a href="https://pf.olibrio.fr/assets/neato/refs/neato-announcement-2025-10-06.md">local copy</a>) that this cloud was going dark. “<em>Your Neato robot will continue to function manually. Simply press the button once to launch a full house run.</em>” A €600 robot demoted to a one-button vacuum, by press release.</p>
<p>And then, a few months later, the button itself stopped responding. Red light, <strong>error 4202</strong>, “battery discharged”. The app, in its dying breaths, specified that the battery&rsquo;s charge counter had been exceeded and that it needed replacing. Original battery: €90, when you can find one. Compatible: €30 to €50, with the well-documented risk on the forums that the robot rejects it.</p>
<p>This post tells how we went from “dead robot, dead battery” to a battery that explains by itself what&rsquo;s wrong — without buying anything, with three wires and a microcontroller that costs the price of a coffee. It also tells of a false hope, because that&rsquo;s what happens when you investigate for real. It pauses on what this investigation would have cost me three years ago, before an AI could read a 250-page manual on my behalf, and on the remarkable little chip that manages this battery. And it explains why this story leaves me with a bitter taste about the way we build objects.</p>
<h2 id="first-reflex-the-cells">First reflex: the cells</h2>
<p>A robot battery is a housing containing several <strong>cells</strong> — lithium accumulators in the 18650 format (18 mm in diameter, 65 mm long, the look of an oversized AA) — wired in series and managed by a small circuit board. A “discharged” battery that won&rsquo;t recharge is classically a case of dead cells. So I did what every tinkerer does: opened the pack, desoldered the four cells, soldered in four salvaged cells in their place — mismatched, which will matter later —, closed it back up.</p>
<p>Result: <strong>still error 4202.</strong></p>
<p>Multimeter in hand (the instrument that measures voltage, i.e. electrical “pressure”, in volts), though, everything was perfect. 4.1 V on every cell, which is a full lithium cell; 16.4 V across the pack; 16.4 V <em>at the output connector</em>, meaning the protection circuit — the electronic switch that can isolate the cells in case of danger — was letting current through. The thermistor (a resistor whose value changes with temperature, which the robot reads to monitor the battery) showed 6 kΩ, a normal value for a warm room. The robot was holding a battery charged to 95% and in good health, and it was displaying “battery discharged”.</p>
<p>That&rsquo;s the moment you understand the problem isn&rsquo;t the battery. It&rsquo;s <strong>what the battery says about itself</strong>.</p>
<details>
<summary>Going deeper: measuring without getting burned</summary>
<p>The pack is 14.4 V nominal (four cells in series, “4S”), 16.8 V when charged. That&rsquo;s not a dangerous voltage for skin, but a <strong>short circuit</strong> — connecting plus and minus directly — on 18650s lets through tens of amps: enough to weld a pair of pliers, melt a sleeve or set a cell on fire. Simple rules: no bracelet, no ring, a single metal tool at a time, and measure on the <strong>B+ / B− pads</strong> (cell side) or <strong>C+ / C−</strong> (connector side) without ever bridging them. The thermistor (a 10 kΩ NTC at 25 °C: its resistance <em>drops</em> when it gets hot) is read between the temperature wire and the minus.</p>
<p>If the voltage is good on the cell side but zero at the connector, the protection circuit has cut out: there, it really is the chip that decided, and we&rsquo;re squarely in the subject of this post.</p>
</details>
<h2 id="the-battery-has-a-brain">The battery has a brain</h2>
<p>The packs in the latest-generation Neatos (D8, D9, D10, D800) are “smart batteries”: next to the cells, the small board carries a <strong>fuel gauge</strong>, a chip — an integrated circuit, a tiny sliver of silicon holding a specialized computer — from Texas Instruments, the <strong>bq40z50-R2</strong>. It&rsquo;s the gauge that measures the current, adds up what goes in and what goes out, derives the state of charge from that, cuts out in case of overvoltage or overheating — and it&rsquo;s the gauge that <em>talks to the robot</em>, over a <strong>serial bus</strong>: two wires carrying digital messages, one wire for the data and one for the clock that paces the conversation. The protocol (the language spoken on those wires) is called <strong>SMBus</strong>, an industrial cousin of the I²C that every Arduino tinkerer knows. An extra blue wire in the pack&rsquo;s connector carries this conversation up to the robot.</p>
<p>The robot doesn&rsquo;t measure the battery&rsquo;s voltage. It <em>asks</em> the gauge: “how charged are you?”, “what charging current do you want?”, “what voltage?”, “how many cycles have you done?”. And it obeys whatever the gauge answers. If the gauge says “0%” or “don&rsquo;t charge me”, the robot displays “battery discharged”, whatever the electrical reality three centimeters away.</p>
<p>This architecture isn&rsquo;t absurd: a gauge inside the pack knows the cells&rsquo; history, handles their balancing (keeping all four cells at the same level of charge, failing which the weakest wears out first), and protects effectively against thermal runaway (the chain reaction that sets a mistreated lithium cell on fire). But it has a consequence: <strong>replacing the cells does not reset the gauge.</strong> The cycle counter, the learned capacity, the error flags (yes/no boxes the chip ticks when it detects a problem) — all of that lives in the chip&rsquo;s memory, not in the cells. You replace the heart, the brain keeps its memories.</p>
<p>On the forums, the trail usually ends there. The chips in the D3–D7 packs are from an obscure manufacturer and locked; for the bq40z50 “you need an EV2400 and bqStudio” — Texas Instruments&rsquo; official adapter, €150, and its Windows software — and nobody really knows which data the robot checks. End of story, buy a battery.</p>
<details>
<summary>Going deeper: SMBus, SBS and what “smart battery” means</summary>
<p>The <em>Smart Battery System</em> (SBS) is a 1990s standard, born for laptops. An SBS battery is a device at SMBus <strong>address</strong> <code>0x0B</code> — on a shared bus, each chip has a number so you know who you&rsquo;re talking to; the <code>0x…</code> notation means the number is written in hexadecimal, base 16, the programmers&rsquo; habit — that answers about a hundred standardized <strong>commands</strong>, each designated by a number: <code>0x09</code> voltage, <code>0x0A</code> current, <code>0x0D</code> relative state of charge (RSOC, the “battery percentage”), <code>0x10</code> full charge capacity, <code>0x17</code> cycle count, <code>0x14</code> / <code>0x15</code> <em>requested</em> charging current and voltage… What a command returns is called a <strong>register</strong>: a small memory slot readable from the outside. A “smart” charger reads those last two registers and supplies exactly what it&rsquo;s asked for. At zero, it supplies nothing.</p>
<p>On top of that, the bq40z50 adds a catch-all command, <code>ManufacturerBlockAccess</code> (<code>0x44</code>), through which you read the internal status registers (safety status, permanent failures, transistor states) and, with sufficient access, <strong>the entire configuration flash memory</strong> — the memory that survives power-off, like a USB stick —, 8 KB from addresses <code>0x4000</code> to <code>0x5FFF</code>, where the cycle counter, the design capacity, the protection thresholds and some fifty options live.</p>
<p>Three access levels exist: <em>Sealed</em> (standard registers readable only), <em>Unsealed</em> (unlocked by a 32-bit key) and <em>Full Access</em> (everything, including writing to flash, behind a second key). TI recommends sealing before leaving the factory.</p>
</details>
<h2 id="three-wires-and-a-two-euro-esp32">Three wires and a two-euro ESP32</h2>
<p>Except that the bq40z50 is a publicly documented chip. Texas Instruments publishes its <em>Technical Reference Manual</em> — the reference manual, 250 pages describing every register, every command, every memory address (<a href="https://pf.olibrio.fr/assets/neato/refs/bq40z50-r2-trm-sluubk0b.pdf">local copy of the PDF</a>, 1.6 MB). And SMBus, any <strong>microcontroller</strong> — a complete computer on a chip, a few euros, the kind found on an Arduino board — can speak as soon as it has two I²C pins.</p>
<p>I have a drawer full of ESP32-C3 “Super Mini” boards: a board the size of a postage stamp, two euros apiece, that plugs into USB and programs like an Arduino.</p>
<p><img alt="The underside of an ESP32-C3 Super Mini: from top to bottom the 5V, G, 3.3, 4, 3, 2, 1, 0 pins; we use G, 4 and 5" src="https://pf.olibrio.fr/images/neato/esp32c3-supermini-pinout.jpg" /></p>
<p>On the pack&rsquo;s board, two <strong>test pads</strong> — contact points deliberately left by the manufacturer to hook up its end-of-line test equipment — are silkscreened <strong>DA</strong> and <strong>CL</strong>: <em>data</em> and <em>clock</em>, the two SMBus wires. One wire on DA, one on CL, one on the cells&rsquo; minus, and on the other end the GPIO 4, GPIO 5 (GPIO: a microcontroller&rsquo;s numbered input/output pins) and GND (ground, the shared “zero volt”) pins of the ESP32. No component to add, no power supply to provide: the gauge is powered by its cells and the ESP32 by its USB.</p>
<p><img alt="Wiring diagram: ESP32-C3 GPIO 4 to the DA pad, GPIO 5 to CL, GND to the cells' minus; C+ at 16.4 V is never touched" src="https://pf.olibrio.fr/images/neato/cablage-esp32c3-bq40z50.svg" /></p>
<p>On the software side, two hundred lines of Arduino, written with an AI (Claude) that read the manual on my behalf: we read the standard registers (voltage, current, state of charge, cycle count, per-cell voltages), then the internal status registers — safety status, permanent failures, state of the charge and discharge transistors —, then we copy out the flash memory. <strong>Nothing is written without an explicit order.</strong> We listen.</p>
<details>
<summary>Going deeper: the wiring, the pull-ups and the code</summary>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Pack (BMS board)</th>
<th>ESP32-C3 Super Mini</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>DA</strong> pad (SMBus data)</td>
<td>GPIO 4 (SDA)</td>
</tr>
<tr>
<td><strong>CL</strong> pad (SMBus clock)</td>
<td>GPIO 5 (SCL)</td>
</tr>
<tr>
<td>Black wire (cells&rsquo; −, B− pad)</td>
<td>G (GND)</td>
</tr>
</tbody>
</table></div>
<p>SMBus needs pull-up resistors (they gently pull each line toward 3.3 V when nobody is talking, failing which the signal floats) on both lines. The ESP32&rsquo;s internal resistors (≈ 45 kΩ) were enough here at 100 kHz over ten centimeters of wire; if the chip doesn&rsquo;t answer the I²C scan, add two 4.7 kΩ resistors between each line and the ESP32&rsquo;s 3.3 V. The gauge tolerates 3.3 V on its bus lines; we <strong>never</strong> connect the ESP32&rsquo;s 3.3 V or 5 V to the pack.</p>
<p>The sketch — that&rsquo;s what Arduino calls a program — (<a href="https://pf.olibrio.fr/assets/neato/bq40z50-reader.cpp"><code>bq40z50-reader.cpp</code></a>, <a href="https://pf.olibrio.fr/assets/neato/platformio.ini"><code>platformio.ini</code></a>) runs in a loop: every ten seconds, a full readout on the <strong>serial port</strong> (the text channel between the board and the computer, read in a “serial monitor”) at 115200 baud (the link speed). It writes to the gauge only on three letters typed into the monitor: <code>r</code> (chip reset), <code>c</code> (cycle counter to zero), <code>C</code> (counter restored to its original value). Everything else is reading.</p>
</details>
<h2 id="what-the-chip-said">What the chip said</h2>
<p>First readout, on the serial monitor:</p>
<pre><code>Voltage            : 16386 mV
RSOC               : 97 %
RemainingCapacity  : 2343 mAh
FullChargeCapacity : 2437 mAh
DesignCapacity     : 2500 mAh
CycleCount         : 1547
ChargingCurrent    : 0 mA
ChargingVoltage    : 0 mV
Cellules           : 4094 / 4097 / 4100 / 4095 mV
ManufacturerName   : BLUEWAY-
DeviceName         : bq40z50-
ManufactureDate    : 2022-01-09
OperationStatus    : PRES DSG CHG SEC0  -&gt; sécurité : FULL ACCESS
SafetyStatus       : (aucun flag)
PFStatus           : (aucun flag)
</code></pre>
<p>To read this: mV and mA are millivolts and milliamps (thousandths of a volt and of an amp); mAh, milliamp-hours, measure a quantity of stored energy — 2,437 mAh is enough to supply 2.4 A for one hour; RSOC is the displayed battery percentage; <em>FullChargeCapacity</em> is what the gauge thinks it can store today, <em>DesignCapacity</em> what the pack stored when new.</p>
<p>Three things jump out.</p>
<p><strong>The gauge is in great shape.</strong> No safety fault, no permanent failure, charge and discharge transistors conducting (the <strong>transistors</strong>, or FETs, are the electronic switches through which the gauge allows or cuts charging and discharging), state of charge estimated at 97% — consistent with 4.1 V per cell. It isn&rsquo;t “discharged”, and it knows it.</p>
<p><strong>It isn&rsquo;t even locked.</strong> The security level is <em>FULL ACCESS</em>: the chip&rsquo;s most permissive mode, the one TI recommends leaving before shipping from the factory. No password to crack, no key to guess. The entire memory is readable and writable. The pack manufacturer delivered the house with the keys in the door.</p>
<p><strong>And yet it tells the robot not to charge it.</strong> <code>ChargingCurrent = 0 mA</code>, <code>ChargingVoltage = 0 mV</code>. Those are the two registers a smart charger reads to know what to do. At zero, they mean: “don&rsquo;t send any current”.</p>
<p>And the counter: <strong>1,547 cycles</strong>. A cycle, for a gauge, is a cumulative discharge equivalent to one full battery — two half-discharges make one cycle. For a battery manufactured in January 2022, that&rsquo;s one cycle per day for four years — plausible for a robot that runs daily. It&rsquo;s also the number the app had held against me.</p>
<details>
<summary>Going deeper: reading the status flags</summary>
<p><code>OperationStatus</code> (command <code>0x44</code> + <code>0x0054</code>) is a 32-<strong>bit</strong> word — thirty-two slots worth 0 or 1, each one a flag. The ones that matter here: <code>PRES</code> (the robot is detected), <code>DSG</code> and <code>CHG</code> (discharge and charge transistors conducting), <code>SEC1:SEC0</code> (security level: <code>01</code> = Full Access, <code>10</code> = Unsealed, <code>11</code> = Sealed), <code>PF</code> (permanent failure: the pack has condemned itself), <code>XCHG</code> / <code>XDSG</code> (charging / discharging forbidden), <code>SLEEP</code> (remember that one). <code>SafetyStatus</code> (<code>0x0051</code>) lists the active protections — overvoltage, undervoltage, overcurrent, temperature; <code>PFStatus</code> (<code>0x0053</code>) the permanent failures, the ones no reset clears: open cell, blown fuse, unrecoverable imbalance. Everything was at zero.</p>
</details>
<h2 id="seventeen-thousand-lines-of-manual-and-a-false-hope">Seventeen thousand lines of manual, and a false hope</h2>
<p>The question becomes: <em>why</em> is the gauge refusing to charge? The TI manual precisely lists the cases in which <code>ChargingVoltage()</code> drops to zero: charge transistor cut by a protection, temperature out of range, permanent failure, pack declared “removed”, charger overvoltage detected. We checked them all, register by register. None was active. Temperature 26 °C, temperature region “recommended”, voltage region “high”, all green.</p>
<p>We also explored a seductive lead: the bq40z50 has a <em>cycle-count degradation</em> feature, which deliberately reduces the charging voltage and current as the pack ages — planned obsolescence in the literal sense, enabled by a configuration bit. We read that bit in the flash memory. <strong>It was zero.</strong> Neato hadn&rsquo;t enabled it. The charging table said 4.2 V per cell and 1.5 A across all temperature ranges. On paper, the gauge should have been announcing 16.8 V and 1,500 mA. It was announcing 0 and 0.</p>
<p>Before going further, we did what you must always do before touching a memory you won&rsquo;t be able to buy back: <strong>a full backup.</strong> 8 KB, 256 blocks of 32 bytes (a byte is eight bits, the smallest unit of memory), read one by one and stored in a file (<a href="https://pf.olibrio.fr/assets/neato/refs/bq40z50-df-backup-neato-blueway-20260829.txt">here it is</a>, if yours is the same pack). If a write goes wrong, everything can be put back.</p>
<p>Then we tried the dumbest thing in the world. The manual documents a command <code>0x0041 : Reset</code> — a chip reset: its RAM (the working memory, wiped at every restart) is rebuilt from flash, nothing is written. The equivalent of “have you tried turning it off and on again?”. One letter <code>r</code> sent over the serial monitor, three seconds of waiting:</p>
<pre><code>&gt;&gt;&gt; Envoi MAC 0x0041 Reset
ChargingCurrent    : 1500 mA
ChargingVoltage    : 16800 mV
RSOC               : 97 %
FET DSG ON, FET CHG ON, PF inactif
</code></pre>
<p>Victory, we thought. Pack reassembled, robot placed on its base: no more error, it charges, it announces it&rsquo;s ready. I wrote a first version of this post that very evening, with a triumphant conclusion about the cycle counter the robot “had never cared about”.</p>
<p><strong>The next morning, red light, error 4202.</strong></p>
<h2 id="the-symptom-that-vanishes-when-you-look-at-it">The symptom that vanishes when you look at it</h2>
<p>Pack hooked back up to the ESP32. <code>ChargingCurrent : 0 mA</code>, again. Reset: 1,500 mA. And this time, instead of crying victory, we let it run and watched the clock.</p>
<p>At 3 seconds: 1,500 mA. At 20 seconds: 1,500 mA. At 31 seconds: <strong>0 mA</strong> — and in the same readout, a flag that wasn&rsquo;t there ten seconds earlier: <code>SLEEP</code>. Two attempts, two full readouts compared line by line: between 20 and 31 seconds, the <em>only</em> thing that changes is that flag.</p>
<p>The gauge <strong>falls asleep</strong>. As soon as almost no current is flowing (less than 10 mA, a threshold set in its configuration), it goes into standby to spare its cells, and in that state it announces “charging current: 0”. The reset never fixed anything: it <em>woke it up</em> for thirty seconds. On the bench, with an ESP32 that draws nothing, it went right back to sleep. Inside the robot, which pulls a few hundred milliamps, it stays awake and announces 1,500 mA. The “don&rsquo;t charge me” was never the problem. It was a laboratory mirage.</p>
<p><img alt="The next day's laboratory: on a couch, the Neato pack under yellow kapton tape connected by three wires to an ESP32-C3, itself plugged by USB into a laptop whose screen shows the gauge readouts" src="https://pf.olibrio.fr/images/neato/labo-canape-esp32-pack.jpg" /></p>
<p><em>The next morning&rsquo;s laboratory: a couch, the pack under kapton (the yellow adhesive tape that insulates and withstands heat), the ESP32 at the end of three wires, and a terminal comparing readouts ten seconds apart.</em></p>
<p>It&rsquo;s a lesson I already knew and still had to relearn: when a symptom disappears <em>at the moment you observe it</em>, it&rsquo;s probably the observation that made it disappear. You have to watch what comes back, not what goes away.</p>
<details>
<summary>Going deeper: the sleep configuration, and an imprecise doc</summary>
<p>In flash, <code>DA Configuration</code> (<code>0x4A7D</code>) is <code>0x001F</code>: four cells, <strong>NR = 1</strong> (pack declared non-removable), <strong>IN_SYSTEM_SLEEP = 1</strong> and <strong>SLEEP = 1</strong>. <code>Sleep Current</code> (<code>0x48A3</code>) = 10 mA, <code>Bus Timeout</code> = 5 s. The TRM (§ 4.12, table of conditions for <code>ChargingVoltage() = 0</code>) only provides for zeroing in sleep if <code>FET Options[SLEEPCHG] = 0</code>; here <code>FET Options</code> (<code>0x4887</code>) is <code>0x7D</code>, so SLEEPCHG = 1, and the charge transistor does in fact stay conducting in sleep. The zeroing of <code>ChargingCurrent()</code> / <code>ChargingVoltage()</code> happens anyway. Real behavior has the last word over the doc — one more reason to measure.</p>
<p>This configuration is the factory one, it hasn&rsquo;t moved: the robot has always lived with a gauge that dozes off at the slightest pause. The Neato simply wakes it up by drawing current.</p>
</details>
<h2 id="the-counter">The counter</h2>
<p>That left the original suspect, the one the app had named: <strong>1,547 cycles</strong>. The gauge itself couldn&rsquo;t care less — cycle degradation is disabled, we checked. But the robot reads that number (<code>CycleCount</code>, command <code>0x17</code>), and an embedded piece of software (a <strong>firmware</strong>: the program burned into the robot) that displays “charge counter exceeded” necessarily has a threshold somewhere.</p>
<p>The counter is a plain two-byte value in flash, at address <code>0x4340</code>. In Full Access, you write it the way you read it: a block command, the address, two bytes. We wrote zero.</p>
<pre><code>&gt;&gt;&gt; CycleCount SBS avant = 1547 ; écriture DF 0x4340 = 0
&gt;&gt;&gt; relecture 0x4340 : 00 00  (OK)
&gt;&gt;&gt; CycleCount SBS après écriture = 0
</code></pre>
<p>Immediate, no reset, no safety flag raised. A brand-new battery, in the eyes of anyone who only looks at that number.</p>
<details>
<summary>Going deeper: writing to the data flash</summary>
<p>TRM § 14.1.67: a flash write is an <em>SMBus block write</em> (sending a packet of bytes in one go) on command <code>0x44</code>, whose block is the start address (little-endian: least significant byte first) followed by 1 to 32 bytes of data. For <code>CycleCount = 0</code>: <code>0x44</code>, length 4, <code>0x40 0x43 0x00 0x00</code>. The block is then read back (block write of the address alone, then block read of <code>0x44</code>, which returns the address and 32 bytes) to verify. The SBS value <code>0x17</code> followed without a reset. The sketch&rsquo;s <code>C</code> command puts back <code>0x0B 0x06</code> (1547) if you want to revert.</p>
<p>What we did <strong>not</strong> touch, and why: <code>Qmax</code> (the cells&rsquo; chemical capacity as the gauge learned it, <code>0x4306…0x430E</code>, five times 2650 mAh) and the internal resistance table date from the original cells; <code>Design Capacity</code> (<code>0x48E5</code>) is 2500 mAh; the state of health the gauge announces (<code>FullChargeCapacity / DesignCapacity</code>) therefore swings between 73% and 92% depending on the time of day. One could cheat by lowering <code>Design Capacity</code>, or force <code>Qmax</code>. We preferred to let the gauge relearn honestly over full cycles — see below. The full, decoded map of the flash is <a href="https://pf.olibrio.fr/assets/neato/refs/bq40z50-df-map-neato.md">here</a>.</p>
</details>
<h2 id="rebondissement">The twist</h2>
<p>September 1, evening: red light. Error 4202. The robot had lasted one day.</p>
<p>Third time the same script plays out: touch the pack, plug it back in, the robot runs again, and the next day it stops. Resetting the gauge had &ldquo;worked&rdquo; for an evening; zeroing the counter, for a day and a few cycles. Looked at closely, those two &ldquo;victories&rdquo; share one thing that isn&rsquo;t the fix: each time, <strong>the pack was unplugged and plugged back in</strong>. The robot reboots, forgets its &ldquo;battery discharged&rdquo; state, tries again — and runs into the same cause, which we hadn&rsquo;t found.</p>
<p>This time, before any reset, the pack went onto the ESP32 as it was. Cycle count: still 0. No active protection, no permanent failure, gauge in perfect health — the same reassuring readout as the first evening. Except we had learned to distrust reassuring readouts, and we went to read a region of the flash we had never decoded: the <strong>lifetime data</strong> (<em>Lifetimes</em>), the logbook the gauge keeps about itself since it left the factory — extreme voltages seen by each cell, how many times each protection has tripped, and at which cycle the last one happened.</p>
<p>Compared with the August 29 backup, it said this:</p>
<pre><code>                              Aug 29       Sep 1
COV (cell over-voltage)       6            246   (+240 in three days)
Lifetime max V, cell 4        4213 mV      4293 mV   (COV threshold: 4225)
Valid charge terminations     21813        21813 (none since the re-celling)
Cells at rest                 —            3986 / 3986 / 3995 / 3939 mV
</code></pre>
<p>Two hundred and forty trips of the <strong>over-voltage</strong> protection in three days, all on cell 4 — and not a single charge run to completion. The original pack had seen six in four years.</p>
<p>The explanation fits in one picture. Four cells in series are four buckets filled through the same hose: if one is smaller, it overflows first. Cell 4 of my salvaged set has less capacity than the other three — at rest it sits lowest (3.94 V against 3.99), and on charge it is the first to cross 4.225 V. The gauge then does its job: it opens the charge transistor, waits for the cell to drop back to 4.1 V, closes it again, opens it a second later. Two hundred and forty times. The robot&rsquo;s charger, meanwhile, sees a charge that never terminates; after a while its firmware draws the only conclusion it knows: &ldquo;battery discharged&rdquo;, 4202.</p>
<p>The cycle counter had nothing to do with it — or not everything. For the original pack we will never know: I changed two things at once. Its lifetime data, though, shows 67 under-voltage events, the last one at cycle 1547; the 41 mΩ cell sagging in the middle of a vacuuming run is at least as good an explanation as the counter. Error 4202 is a catch-all.</p>
<p><strong>The new attempt.</strong> The clean solution is to run the four salvaged cells through the tester, as with the originals, and rebuild a pack from the four best-matched cells among the eight. In the meantime, the gauge allows a stopgap with no soldering iron, still through the three wires — and that is what we did the same evening:</p>
<ul>
<li><strong>charging voltage lowered from 4200 to 4100 mV per cell</strong>: the pack stops at 16.4 V instead of 16.8; cell 4 stays below the over-voltage threshold, at a cost of about 10% runtime;</li>
<li><strong>reference capacity brought down to 2100 mAh</strong> (it had stayed at 2500, the new pack&rsquo;s figure), the cells&rsquo; chemical capacity aligned with it;</li>
<li><strong>gauge put back into learning mode</strong>: its learned values (chemical capacity, resistance table) dated from the 2022 cells; one bit tells it to relearn everything over the next cycles.</li>
</ul>
<p>Reset: the gauge reports 16,400 mV and 1500 mA, an estimated full-charge capacity of 1792 mAh, no fault. Pack closed up, robot on its dock. This time, no victory lap: we wait a few days, then reopen the gauge&rsquo;s logbook. If the over-voltage counter still reads 246, that was it.</p>
<details>
<summary>Going deeper: the lifetime data, and what we wrote</summary>
<p>The <em>Lifetimes</em> region spans flash <code>0x4380</code> to <code>0x43F7</code> (TRM ch. 18): min/max voltage of each cell (<code>0x4380</code>), then for each protection an event counter and the cycle count at the last event — COV at <code>0x43A0</code>/<code>0x43A2</code>, CUV at <code>0x43A4</code>/<code>0x43A6</code>, and so on —, then valid charge terminations (<code>0x43D0</code>), Qmax/Ra updates (<code>0x43D4</code>…), running time. The COV threshold lives at <code>0x494E</code> (4225 mV here, 1 s delay, 4100 mV recovery at <code>0x4959</code>).</p>
<p>Writes of September 1, all read back afterwards: charging voltage per cell in the charge algorithm&rsquo;s five temperature regions (<code>0x4A19</code>, <code>0x4A21</code>, <code>0x4A29</code>, <code>0x4A31</code>, <code>0x4A39</code>: 4200 → 4100); <em>Design Capacity</em> <code>0x48E5</code> = 2100 mAh and <code>0x48E7</code> = 3024 cWh; <em>Qmax</em> cells 1–4 and pack <code>0x4306</code>–<code>0x430E</code> = 2100; <em>Qmax Cycle Count</em> <code>0x4310</code> = 0; <em>Update Status</em> <code>0x4312</code> = 0x04 (Impedance Track enabled, &ldquo;not yet learned&rdquo;: the gauge will redo Qmax, then the Ra table, over the next cycles, TRM 15.13.8.2 and 6.4.6); then reset <code>0x0041</code>. Checked before writing: the &ldquo;fully charged&rdquo; flags (FC/TC) are set here by valid charge termination (current under 130 mA, cell within 75 mV of the charging voltage), not by a fixed 4200 mV threshold — lowering the voltage doesn&rsquo;t break them. Margin on cell 4: some thirty millivolts under the threshold; the sketch has a command to go down to 4050 if that isn&rsquo;t enough. Commands: <code>v</code>/<code>w</code>/<code>V</code> (voltage 4100 / 4050 / 4200), <code>d</code>/<code>D</code> (capacity and learning / factory values).</p>
</details>
<h2 id="ou-on-en-est">Where things stand</h2>
<div class="status">
<p><strong>Status as of September 1, 2026: under observation.</strong> Error 4202 came back on September 1 after a day of operation; the real cause is a weaker salvaged cell tripping the over-voltage protection on every charge, 240 times in three days (see <a href="#rebondissement">The twist</a>). Stopgap applied the same evening through the three wires: charging voltage 4100 mV per cell, reference capacity 2100 mAh, gauge relearning. The robot is on its dock; verdict in a few days, by reopening the gauge&rsquo;s logbook — if the over-voltage counter hasn&rsquo;t moved, that was it. Still not a single part bought.</p>
</div>
<p>And then there&rsquo;s the epilogue I hadn&rsquo;t seen coming. The four <em>original</em> cells, the ones I had desoldered at the start because “the battery was dead”, were lying around on the bench. For good measure, I measured them: <strong>3.6 V each.</strong> None is below the threshold where a lithium cell condemns itself. So I ran them through a capacity tester — a Liitokala Lii-500, a thirty-euro charger-analyser, in “NOR TEST” mode: it charges the cell fully, then discharges it at constant current while counting the milliamp-hours that come out, then recharges it. That&rsquo;s the only honest way to know what a cell is worth: empty it with a stopwatch running.</p>
<p>The verdict, on cells rated <strong>2,500 mAh</strong> when new:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Cell</th>
<th>Measured capacity</th>
<th>Share of nominal</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td>1,916 mAh</td>
<td>77%</td>
</tr>
<tr>
<td>B</td>
<td>1,912 mAh</td>
<td>76%</td>
</tr>
<tr>
<td>C</td>
<td>1,855 mAh</td>
<td>74%</td>
</tr>
<tr>
<td>D</td>
<td>1,819 mAh</td>
<td>73%</td>
</tr>
</tbody>
</table></div>
<p>The tester also gives each cell&rsquo;s <strong>internal resistance</strong> — the electrical “friction” of the chemistry, in milliohms (mΩ): the higher it is, the more the voltage collapses when the robot draws current, and the more the cell heats up instead of delivering. Three cells sit between <strong>16 and 19 mΩ</strong>, which is good for 18650s of that age. The fourth is at <strong>41 mΩ</strong>: more than double. That&rsquo;s the pack&rsquo;s weak link — the one that sags first mid-vacuum, drags the others toward the cutoff threshold and therefore shortens the runtime far more than its 1,819 mAh would suggest.</p>
<p>But none of them is dead. After something like 1,547 cycles, these cells still deliver three quarters of their original capacity: a normal loss, the one you expect from a lithium cell reaching the end of its advertised life. In practice, the runtime would have gone from a full hour and a half to a little over an hour. A slightly less enduring vacuum — not a dead one.</p>
<p>That&rsquo;s where the story finds its point. The pack that set all this off wasn&rsquo;t faulty: it was <strong>25% worn</strong>, and something — a counter, or more likely its 41 mΩ cell sagging mid-run — decided that was the end. The recelling I did on first reflex probably wasn&rsquo;t necessary, and done with salvaged cells that were never measured, it added a problem of its own (see <a href="#rebondissement">The twist</a>); at least it leaves me four working original cells, measured and documented, ready to serve as spares the day the salvaged ones give out.</p>
<h2 id="what-it-would-have-cost-me-three-years-ago">What it would have cost me three years ago</h2>
<p>Let&rsquo;s be honest about the step I climbed without seeing it.</p>
<p>Three years ago, this story would have ended at the paragraph “First reflex: the cells”. Cells replaced, error still there, multimeter unequivocal: the battery is good. And then? Then, a <strong>dead end</strong>. I would have searched the forums, found the same threads as everyone else — “you need an EV2400 and bqStudio”, “the chip is locked”, “buy a battery” — and I would have either bought, or put the robot in a closet waiting for a courage that never comes.</p>
<p>Because the rest of the process, which fits here in a few paragraphs, represents work I couldn&rsquo;t have delivered in a tinkerer&rsquo;s evenings. Let&rsquo;s lay it out:</p>
<ol>
<li><strong>Identify the chip</strong> and find its manual: easy.</li>
<li><strong>Read the manual</strong>: 250 pages, 17,000 lines of dense technical English, written for battery engineers who already know the vocabulary. It&rsquo;s not about skimming it: you have to find <em>the</em> three tables that say when the charging current drops to zero, the flash write procedure, the map of the 400 configuration addresses, the meaning of every bit in a dozen status registers.</li>
<li><strong>Learn SMBus</strong> at the protocol level (the <em>block read</em>, the <em>block write</em>, command <code>0x44</code> and its echo, address auto-increment) and write a program that speaks it without error.</li>
<li><strong>Interpret</strong> a readout of forty flags and 8 KB of raw bytes, cross-referencing them with the manual.</li>
<li><strong>Design the experiments</strong>: the reset, then — after the false hope — the idea of comparing two full readouts ten seconds apart to isolate <em>the</em> line that changes.</li>
<li><strong>Write</strong> to the flash without breaking anything.</li>
</ol>
<p>For a competent developer who isn&rsquo;t a battery engineer, that&rsquo;s several weeks of evenings, with a real risk of giving up at step 2 — and that&rsquo;s exactly where the forum threads stop. It&rsquo;s not that it&rsquo;s <em>hard</em> in the intellectual sense; it&rsquo;s that it&rsquo;s <em>long</em>, and nothing guarantees, at the outset, that those weeks lead anywhere.</p>
<p>With Claude, steps 2 through 6 took two evenings and a morning. The AI absorbed the manual in a few seconds, wrote the sketch in one pass, decoded the registers as they came in, suggested the reset, then — when the reset lied — suggested doing it again while recording everything, and found the <code>SLEEP</code> flag in the comparison. What I kept for myself: the soldering iron, the multimeter, the photos, the decisions (“we don&rsquo;t touch Qmax, we let it learn”), and above all the doubt: it&rsquo;s the overnight test in the real robot that disproved the victory, not one more readout.</p>
<p>Two caveats, so as not to turn this into a fairy tale. First, the AI co-signed the false hope: it had the same wrong conclusion as I did the first evening, because we had the same incomplete data. It reads fast, it doesn&rsquo;t measure on my behalf. Second, it found an error in the manual itself (the sleep mode that zeroes the current while the doc says otherwise) only because we <em>measured</em> the opposite; without the ESP32 hooked up, it would have defended the doc.</p>
<p>But the shift is real, and it seems important to me for repair in general: <strong>a body of knowledge that was locked inside a 250-page manual, and therefore reserved to professionals, becomes accessible to someone who can hold a soldering iron and ask a question.</strong> Manufacturers count, without saying so, on nobody reading the manual. That assumption just fell.</p>
<h2 id="focus-the-bq40z50-the-chip-that-manages-your-battery">Focus: the bq40z50, the chip that manages your battery</h2>
<p>Since we spent two days with it, let&rsquo;s introduce it. The bq40z50-R2 is what Texas Instruments calls a <em>battery pack manager</em>: a single chip, the size of a fingernail (32-pin QFN package, the contacts are underneath the package), that does everything a lithium pack of 1 to 4 cells in series needs done. It&rsquo;s found in a large share of laptop batteries from the past ten years, in power tools, robots, drones, medical batteries.</p>
<h3 id="what-it-does">What it does</h3>
<ul>
<li><strong>Gauging</strong>: it measures the current through a sense resistor (a <em>shunt</em>, a few milliohms) and the voltage of each cell, and derives the state of charge with an in-house algorithm, <em>Impedance Track</em>: instead of merely counting what goes in and out (which drifts over time), it <strong>learns</strong> the cells&rsquo; real chemical capacity (Qmax) and their internal resistance at every cycle, and corrects continuously. That&rsquo;s what gives recent laptops their precise percentages.</li>
<li><strong>Protecting</strong>: it integrates the <em>AFE</em> (<em>analog front-end</em>: the analog part that monitors voltages and currents in real time, independently of the software) and directly drives the charge and discharge transistors. Per-cell overvoltage and undervoltage, overcurrent in charge and in discharge, short circuit, overheating and undertemperature, each with its thresholds, its delays and its recovery conditions.</li>
<li><strong>Condemning itself</strong>: a second family of protections, the <em>permanent failures</em> (PF), cuts the pack off for good — if need be by blowing an irreversible <strong>chemical fuse</strong> — in case of an open cell, uncorrectable imbalance, a burnt transistor or too many repeated overvoltages.</li>
<li><strong>Balancing</strong>: during charging, it slightly discharges the cells that are ahead so that all of them reach full together.</li>
<li><strong>Remembering</strong>: cycle counter, extreme temperatures and voltages seen over its lifetime (<em>lifetime data</em>), and a <strong>black box</strong> that records the last safety events before a permanent failure — enough to perform an autopsy on a dead pack.</li>
<li><strong>Talking</strong>: SMBus, the whole SBS standard, plus the manufacturer&rsquo;s commands. And <strong>authenticating</strong>: a cryptographic mechanism (SHA-1 with a secret key) lets the device verify that the pack is genuine. That&rsquo;s what blocks compatible batteries on some devices.</li>
</ul>
<h3 id="its-strengths">Its strengths</h3>
<ul>
<li><strong>Complete and documented.</strong> Everything is in a single package, and the manual describes everything: that&rsquo;s what made this investigation possible. Many competing chips are documented under NDA, or not at all.</li>
<li><strong>Precise.</strong> Impedance Track remains the benchmark in consumer gauging; the estimation error drops to 1% on a well-configured pack.</li>
<li><strong>Safe by construction.</strong> The AFE protects even if the software crashes; permanent failures are non-negotiable.</li>
<li><strong>Ubiquitous.</strong> Tens of millions of units in laptop packs: boards, configuration files and field reports abound.</li>
<li><strong>Openable.</strong> The official bqStudio software and the EV2400 adapter exist, but as we&rsquo;ve seen, a two-euro microcontroller is enough to read, and often to write.</li>
</ul>
<h3 id="its-weaknesses">Its weaknesses</h3>
<ul>
<li><strong>Complex to configure.</strong> Over 400 parameters in flash. Getting Impedance Track to work properly requires a <em>golden image</em> built in a lab: identification of the cell chemistry, calibration, a full learning cycle. A poorly configured DIY pack gauges poorly, and the <em>learning</em> we chose takes several cycles.</li>
<li><strong>Security depends on the pack manufacturer.</strong> TI supplies the locks; it&rsquo;s the assembler who chooses to close them. The Neato pack was shipped in Full Access. A brand-name laptop battery, on the other hand, is almost always sealed, and without the keys you only read the standard registers.</li>
<li><strong>Security, conversely, can be a wall.</strong> SHA-1 authentication and sealing are precisely what makes some packs unrepairable: a permanent failure on a sealed pack is a trash can, whatever the state of the cells.</li>
<li><strong>No more than 4 cells in series.</strong> For an e-bike (10 to 13 cells in series) you need its big brother, the bq40z80 (up to 7 in series), or another architecture.</li>
<li><strong>Hard to wire yourself.</strong> The QFN package is soldered with a reflow oven or hot air, not an iron; TI&rsquo;s development boards cost around a hundred euros. In practice, DIY goes through salvaged boards.</li>
</ul>
<h3 id="what-you-can-do-with-it-beyond-the-neato">What you can do with it, beyond the Neato</h3>
<ul>
<li><strong>Diagnose a laptop battery before throwing it away.</strong> A “dead” Dell, HP or Lenovo pack very often contains a bq40z50 (or a cousin: bq30z55, bq40z80) and six cells, three of which are fine. The ESP32 and this post&rsquo;s sketch read the state of health, the counter, the faults, and tell you whether it&rsquo;s the cells or the chip. On a sealed pack you read less, but you read.</li>
<li><strong>Rearm a pack after recelling.</strong> Exactly this post: counter, possibly permanent failure (<code>PFStatus</code> is cleared by command, in Full Access or with the key), then a learning cycle.</li>
<li><strong>Build a “smart battery” for your own projects.</strong> A salvaged BMS board with its bq40z50, four new 18650 cells, and you get a 14.4 V pack that protects itself, balances itself and announces its percentage: for a DIY robot, a field radio, a backup UPS for a home server, a work light.</li>
<li><strong>Monitor a battery remotely.</strong> The ESP32 has Wi-Fi: the same sketch can publish voltage, current, temperature and state of charge over MQTT (a lightweight messaging protocol, the home-automation standard) to Home Assistant or a database. A pack that warns before it gives out.</li>
<li><strong>Learn.</strong> The bq40z50 is a complete course in lithium battery management, with a free manual and a chip you can find in the trash. Everything an electric car&rsquo;s BMS does, it does in miniature, and it lets you watch it work.</li>
</ul>
<p>One reservation: all of this assumes an <strong>open</strong> (unsealed) pack, or one whose keys you have. Laptop manufacturers seal; manufacturers of packs for robots, tools and toys often forget. Check before you dive in — it&rsquo;s the first readout, and it costs nothing.</p>
<h2 id="what-this-says">What this says</h2>
<p>I don&rsquo;t believe anyone, at Neato or at its battery supplier, <em>decided</em> this robot would die in 2026. I believe something worse: nobody decided it would <em>live</em>.</p>
<p>Look at the layers.</p>
<p><strong>The cloud.</strong> The robot was designed to do nothing without a server. When the server goes off, only the button remains. On the D3 through D7, a community managed to hook an ESP32 onto the motherboard&rsquo;s serial port and give the robots back their autonomy (<a href="https://github.com/vacuula/fang">vacuula/fang</a>, <a href="https://github.com/renjfk/OpenNeato">OpenNeato</a>). On the D8 through D10, that port is <em>password-locked</em>. One generation later, the door was locked shut. Not for the user&rsquo;s safety — for nothing, apparently, except so they wouldn&rsquo;t touch it.</p>
<p><strong>The authenticated battery.</strong> The robot verifies the pack&rsquo;s identity. The forums are full of D5s bricked after a compatible battery was inserted. The manufacturer calls that security; it&rsquo;s also, very concretely, a toll booth on the number one wear part.</p>
<p><strong>The counter.</strong> 1,547 cycles, and an app that says “needs replacing”. Not because the battery would be unusable — the gauge itself reported it at 97% charged, and the original cells, measured since, still deliver 73 to 77% of their new capacity — but because a number crossed a threshold nobody shows you. The robot was taken out of service over a quarter less runtime.</p>
<p><strong>And the absence of a path.</strong> A perfectly documented chip, left in full access, whose counter resets in a single command. No path, anywhere in the product, leads to that command. The diagnosis delivered to the user is “battery discharged”, which is false, and the proposed solution is “buy”, which is expensive.</p>
<p>Obsolescence, here, isn&rsquo;t a time bomb programmed by a cynical engineer. It&rsquo;s a sum of non-decisions: nobody budgeted a “reset the battery” feature, nobody wrote the honest error message, nobody left a port open. Each choice is justifiable on its own. Their sum manufactures €600 of waste with a charged battery inside.</p>
<p>The good news is that the same non-decisions leave cracks. The chip wasn&rsquo;t locked. The pack manufacturer left its test pads in place. TI publishes its manual. And a two-euro microcontroller, plus an AI willing to read 17,000 lines of documentation without complaining, are enough to slip through.</p>
<p>From February 18, 2027, Article 11 of the <a href="https://eur-lex.europa.eu/eli/reg/2023/1542/oj">European battery regulation</a> (<a href="https://pf.olibrio.fr/assets/neato/refs/eu-regulation-2023-1542-article-11.md">local excerpt</a>) will require that portable batteries in appliances be “<em>readily removable and replaceable by the end-user</em>”. That&rsquo;s progress. But this story shows that “replaceable” isn&rsquo;t enough: my battery <em>was</em> replaceable, and replacement was precisely what I was being sold as the only way out. What we should demand is that objects <strong>tell the truth about their condition</strong>, and that they leave a path to correct it. A “reset the battery” button in an app costs an afternoon of development. Silence costs one battery per customer.</p>
<h2 id="pour-reproduire">How to reproduce it</h2>
<p><strong>Hardware</strong>: an ESP32-C3 Super Mini (or any ESP32/Arduino with I²C), three wires, a soldering iron, possibly two 4.7 kΩ resistors. The pack must be opened at its circuit board — the sleeve is glued, a box cutter splits it without forcing.</p>
<p><strong>Safety</strong>: the cells stay connected throughout the operation. <strong>No metal tool near the C+ and C− pads</strong>, which carry 16 V and several tens of amps of possible short-circuit current. No bracelet, no ring. The DA and CL pads, on the other hand, sit at 3.3 V and risk nothing.</p>
<p><img alt="The other side of the pack's board: the T1, T3, T6, T7 and HEAT test pads, the red wire of the plus and the black wire of the minus" src="https://pf.olibrio.fr/images/neato/pack-bplus-bminus.jpg" /></p>
<p><em>The other side of the board: we don&rsquo;t touch it, but it shows the manufacturer provided far more test points than the two we care about.</em></p>
<p><strong>Wiring</strong>:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Pack (BMS board)</th>
<th>ESP32-C3</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>DA</strong> pad</td>
<td>GPIO 4 (SDA)</td>
</tr>
<tr>
<td><strong>CL</strong> pad</td>
<td>GPIO 5 (SCL)</td>
</tr>
<tr>
<td>Black wire (cells&rsquo; −)</td>
<td>G (GND)</td>
</tr>
</tbody>
</table></div>
<p><strong>Software</strong>: <a href="https://pf.olibrio.fr/assets/neato/bq40z50-reader.cpp"><code>bq40z50-reader.cpp</code></a> and <a href="https://pf.olibrio.fr/assets/neato/platformio.ini"><code>platformio.ini</code></a> (PlatformIO is the tool that compiles and uploads the program; Arduino framework, target <code>esp32-c3-devkitm-1</code>). Serial monitor at 115200 baud. The essential registers, at SMBus address <code>0x0B</code>:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Register</th>
<th>Contents</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0x09</code> / <code>0x0A</code></td>
<td>pack voltage / current</td>
</tr>
<tr>
<td><code>0x0D</code></td>
<td>state of charge (RSOC, %)</td>
</tr>
<tr>
<td><code>0x10</code> / <code>0x18</code></td>
<td>full charge capacity / design capacity</td>
</tr>
<tr>
<td><code>0x14</code> / <code>0x15</code></td>
<td>requested charging current / voltage (0 in sleep: normal)</td>
</tr>
<tr>
<td><code>0x17</code></td>
<td>cycle count</td>
</tr>
<tr>
<td><code>0x3C</code>–<code>0x3F</code></td>
<td>cell voltages 4 to 1</td>
</tr>
<tr>
<td><code>0x44</code> + <code>0x0054</code></td>
<td>OperationStatus (security, FETs, PF, SLEEP)</td>
</tr>
<tr>
<td><code>0x44</code> + <code>0x0053</code></td>
<td>PFStatus (permanent failures)</td>
</tr>
<tr>
<td><code>0x00</code> ← <code>0x0041</code></td>
<td>gauge reset</td>
</tr>
<tr>
<td><code>0x44</code> ← <code>0x40 0x43</code> + 2 bytes</td>
<td>cycle counter write (flash <code>0x4340</code>)</td>
</tr>
<tr>
<td><code>0x44</code> ← <code>0x80 0x43</code> … <code>0xE0 0x43</code></td>
<td>lifetime data read (flash <code>0x4380</code>–<code>0x43FF</code>): COV/CUV counters, charge terminations</td>
</tr>
<tr>
<td><code>0x44</code> ← <code>0x19 0x4A</code> + 2 bytes (×5)</td>
<td>charging voltage per cell (flash <code>0x4A19</code>…<code>0x4A39</code>)</td>
</tr>
</tbody>
</table></div>
<p><strong>In order</strong>: (1) connect, read, check that <code>DeviceType</code> answers <code>0x4500</code> and that the security level is Full Access — otherwise you&rsquo;ll need the manufacturer&rsquo;s keys, and this post doesn&rsquo;t have them; (2) back up the flash (the sketch does it at startup) and put the file somewhere safe; (3) read <code>PFStatus</code> and <code>SafetyStatus</code> — a permanent failure is another story; (4) read the lifetime data (<code>0x4380</code>…) and compare it with the backup after a few days in the robot: a COV or CUV counter that climbs points to a cell, not to a counter; (5) only then, type <code>c</code> — and wait several days before concluding.</p>
<p>Three rules, if you go for it: read first, back everything up next, write only last. A gauge in <em>Full Access</em> lets itself be destroyed just as readily as repaired.</p>
<h2 id="frequently-asked-questions">Frequently asked questions</h2>
<h3 id="what-does-error-4202-mean-on-a-neato">What does error 4202 mean on a Neato?</h3>
<p>It&rsquo;s the “battery discharged” error on the D8, D9, D10 and D800. The app (when it worked) specified that the battery&rsquo;s charge counter had been exceeded and that it needed replacing. It appears even with cells in perfect condition: what triggers it is what the pack&rsquo;s chip tells the robot — in particular, from what we observed, a cycle counter that&rsquo;s too high.</p>
<h3 id="does-replacing-the-packs-cells-fix-error-4202">Does replacing the pack&rsquo;s cells fix error 4202?</h3>
<p>Not on its own. The cycle counter and the state of health live in the pack&rsquo;s bq40z50 chip, not in the cells. After recelling, the error persisted for me with cells at 4.1 V.</p>
<h3 id="do-you-need-an-ev2300ev2400-and-bqstudio-to-talk-to-the-gauge">Do you need an EV2300/EV2400 and bqStudio to talk to the gauge?</h3>
<p>No. An ESP32 (or an Arduino) with two I²C pins, three wires and the TI manual are enough: the chip is documented, and on the Neato packs I&rsquo;ve seen it&rsquo;s left in Full Access, with no password. bqStudio is more comfortable, not necessary.</p>
<h3 id="does-an-off-the-shelf-compatible-battery-solve-the-problem">Does an off-the-shelf compatible battery solve the problem?</h3>
<p>Sometimes, and sometimes the robot rejects it — the forums document D5s bricked after a compatible pack was inserted. Before buying, it costs one evening to check that the original battery hasn&rsquo;t simply crossed a cycle threshold.</p>
<h3 id="is-it-dangerous">Is it dangerous?</h3>
<p>The DA and CL lines sit at 3.3 V and are risk-free. The danger is elsewhere: the C+/C− and B+/B− pads at 16 V, capable of tens of amps in a short circuit. A single metal tool at a time, no jewelry, and touch nothing but DA, CL and the minus.</p>
<h3 id="why-does-the-gauge-report-0-ma-of-charging-current">Why does the gauge report 0 mA of charging current?</h3>
<p>Because it&rsquo;s asleep. As soon as the current drops below 10 mA for about thirty seconds, it enters sleep mode and sets ChargingCurrent and ChargingVoltage to zero. Inside the robot, which draws current continuously, it stays awake. It&rsquo;s not a fault, and a reset doesn&rsquo;t “fix” anything: it wakes it up for thirty seconds.</p>
<h3 id="does-this-work-on-a-d3-d4-d5-d6-or-d7">Does this work on a D3, D4, D5, D6 or D7?</h3>
<p>The packs of those generations use a different, locked chip, and community research hasn&rsquo;t gotten anywhere. On the other hand, their serial port is open, and vacuula/fang or OpenNeato give those robots back their autonomy without the cloud.</p>
<h3 id="can-the-bq40z50-chip-from-an-old-battery-be-reused-in-another-project">Can the bq40z50 chip from an old battery be reused in another project?</h3>
<p>Yes, if the pack isn&rsquo;t sealed or if you have its keys. A salvaged BMS board with its cells replaced makes a 1S to 4S smart battery for a robot, a radio or a DIY UPS; an ESP32 hooked onto it can publish its status over MQTT to your home automation. See the section “Focus: the bq40z50”.</p>
<h3 id="what-exactly-did-the-ai-do-and-what-did-the-human-do">What exactly did the AI do, and what did the human do?</h3>
<p>Claude read the 250-page manual, wrote the readout program, decoded the registers, suggested the reset and then the comparison experiment that revealed the sleep mode, and wrote this post with me. I soldered, measured, photographed, decided not to touch Qmax, and let the robot spend a night on its base — which disproved the first conclusion, its own as much as mine.</p>
<h3 id="is-the-robot-repaired">Is the robot repaired?</h3>
<p>Not for certain yet. Resetting the counter (August 30) bought it one day, then error 4202 returned: the real cause was a weaker salvaged cell tripping the over-voltage protection on every charge. Fix applied on September 1 (charging voltage 4100 mV per cell, gauge relearning), under observation. Still not a single part bought. See <a href="#ou-on-en-est">Where things stand</a>.</p>
<h2 id="a-short-glossary">A short glossary</h2>
<ul>
<li><strong>Cell</strong>: the elementary accumulator, here in the 18650 format (18 mm × 65 mm). A pack assembles several of them.</li>
<li><strong>BMS</strong> (<em>battery management system</em>): the pack&rsquo;s circuit board, which protects, balances and gauges the cells. Here, it&rsquo;s built around the bq40z50.</li>
<li><strong>Gauge (gas gauge)</strong>: the chip that counts what goes in and out of the battery and derives the state of charge from it.</li>
<li><strong>Chip / integrated circuit</strong>: a computer or a complete circuit etched onto a few millimeters of silicon.</li>
<li><strong>Microcontroller</strong>: a small complete computer on a chip, programmable; the ESP32-C3 is one.</li>
<li><strong>Serial bus / SMBus / I²C</strong>: two wires (data and clock) over which chips exchange digital messages; SMBus is the variant used by batteries.</li>
<li><strong>Address, register, command</strong>: a chip&rsquo;s number on the bus, a memory slot that can be read, the number that designates it. <code>0x…</code>: a number written in hexadecimal.</li>
<li><strong>Bit, byte</strong>: the elementary slot (0 or 1) and the group of eight; a <strong>flag</strong> is a bit that signals a state.</li>
<li><strong>Firmware</strong>: the program burned into a device. <strong>Flash</strong>: the memory that survives power-off; <strong>RAM</strong>: the working memory, wiped on restart.</li>
<li><strong>Cycle</strong>: a cumulative discharge equivalent to 90% of the capacity (adjustable threshold). The counter only ever adds up.</li>
<li><strong>RSOC</strong>: relative state of charge, the “battery percentage”; <strong>FCC</strong>: full charge capacity, as estimated by the gauge; <strong>Qmax</strong>: the cells&rsquo; learned chemical capacity; <strong>mAh</strong>: milliamp-hour, unit of stored energy.</li>
<li><strong>Internal resistance</strong>: a cell&rsquo;s electrical “friction”, in milliohms (mΩ). The higher it is, the more the voltage drops under load and the more the cell heats up; it rises with age and betrays a tired cell before its capacity collapses.</li>
<li><strong>FET / transistor</strong>: the electronic switch through which the gauge allows or cuts charging and discharging.</li>
<li><strong>Pull-up</strong>: a resistor that holds a bus line at 3.3 V when nobody is talking.</li>
<li><strong>PF (Permanent Failure)</strong>: a fault the gauge deems unrecoverable; it condemns itself and nothing clears it. There were none.</li>
<li><strong>COV / CUV (Cell Over-/Under-Voltage)</strong>: per-cell over-voltage and under-voltage protections. The gauge cuts the charge (COV) or the discharge (CUV), resumes when the voltage is back in range, and counts every trip in its lifetime data.</li>
<li><strong>Impedance Track</strong>: TI&rsquo;s algorithm that learns the cells&rsquo; capacity and resistance to gauge precisely.</li>
<li><strong>Full Access / Unsealed / Sealed</strong>: the chip&rsquo;s three access levels, from most open to most closed.</li>
</ul>
<hr />
<h2 id="references-and-local-copies">References and local copies</h2>
<p>So that this post stays useful once the links have vanished, the documents cited are hosted here, along with their original source.</p>
<ul>
<li><strong>TI bq40z50-R2 Technical Reference Manual</strong>, SLUUBK0B (June 2017, rev. October 2018) — <a href="https://www.ti.com/lit/pdf/sluubk0">source</a> · <a href="https://pf.olibrio.fr/assets/neato/refs/bq40z50-r2-trm-sluubk0b.pdf">local copy (PDF, 1.6 MB)</a>.</li>
<li><strong>Map of the Neato pack&rsquo;s data flash, decoded</strong> — <a href="https://pf.olibrio.fr/assets/neato/refs/bq40z50-df-map-neato.md">bq40z50-df-map-neato.md</a> · <strong>raw backup</strong> from August 29, 2026 — <a href="https://pf.olibrio.fr/assets/neato/refs/bq40z50-df-backup-neato-blueway-20260829.txt">bq40z50-df-backup-neato-blueway-20260829.txt</a>.</li>
<li><strong>The sketch and its configuration</strong> — <a href="https://pf.olibrio.fr/assets/neato/bq40z50-reader.cpp">bq40z50-reader.cpp</a> · <a href="https://pf.olibrio.fr/assets/neato/platformio.ini">platformio.ini</a>.</li>
<li><strong>Neato Robotics, “Announcement – 6th Oct 2025”</strong> (end of cloud services) — <a href="https://support.neatorobotics.com/support/solutions/articles/204000073686-announcement-6th-oct-2025">source</a> · <a href="https://pf.olibrio.fr/assets/neato/refs/neato-announcement-2025-10-06.md">text copy</a>.</li>
<li><strong>Regulation (EU) 2023/1542 on batteries</strong>, Article 11 (applicable from February 18, 2027) — <a href="https://eur-lex.europa.eu/eli/reg/2023/1542/oj">EUR-Lex source</a> · <a href="https://pf.olibrio.fr/assets/neato/refs/eu-regulation-2023-1542-article-11.md">local excerpt</a>.</li>
<li><strong>vacuula/fang</strong> (ESP32 firmware freeing the Neato D3–D7 from the cloud) — <a href="https://github.com/vacuula/fang">source</a> · <a href="https://pf.olibrio.fr/assets/neato/refs/vacuula-fang-README-20260830.md">README as of August 30, 2026</a>.</li>
<li><strong>renjfk/OpenNeato</strong> — <a href="https://github.com/renjfk/OpenNeato">source</a> · <a href="https://pf.olibrio.fr/assets/neato/refs/renjfk-OpenNeato-README-20260830.md">README as of August 30, 2026</a>.</li>
<li><strong>laptopu.ro thread on the Neato packs&rsquo; chips</strong> (Full Access via bqStudio) — <a href="https://www.laptopu.ro/community/laptop-battery-chip-reset-and-repair/robot-neato-battery-nlba-reads-it-but-can-not-recognize-chip/">source</a> (no copy: the site refuses archiving).</li>
</ul>
<p><em>Photos and measurements: mine. Manual reading, code and writing: with Claude. This post is also available as <a href="https://pf.olibrio.fr/en/posts/erreur-4202-neato-obsolescence.md">raw Markdown</a>.</em></p>]]></content:encoded>
    </item>
    <item>
      <title>The Fire, the Model, and What a Different Policy Would Have Changed</title>
      <link>https://pf.olibrio.fr/en/posts/ce-quune-autre-politique-aurait-change.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/ce-quune-autre-politique-aurait-change.html</guid>
      <pubDate>Tue, 28 Jul 2026 12:00:00 +0000</pubDate>
      <description>Six days of megafire in Gironde and the Landes, a model that learns to represent firefighting, and a stinging question: what would another policy have changed?</description>
      <category>wildfire</category>
      <category>gironde</category>
      <category>landes</category>
      <category>simulation</category>
      <category>firefighting</category>
      <category>politics</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/kokusho/boucle-25-juillet.png" alt="The Fire, the Model, and What a Different Policy Would Have Changed"></p>
<p>The <a href="https://pf.olibrio.fr/en/posts/mon-modele-faisait-bruler-latlantique.html">first post</a> told the story of building the tool and ended on its most uncomfortable lesson: the model does not predict what the fire will do, it predicts what it would do <em>if nobody stood in its way</em>. The gap between the two—a factor of 1.7 on the day the fire ran free, a factor of 12 on the day the crews held the front—is the firefighting effort itself, and the model did not represent it at all.</p>
<p>This post tells four stories: what the fire has done since, what the model learned by trying to represent the crews, what the real fight owes to an army nobody budgets for—residents and farmers—and what this model allows one to say—carefully—about a political question I was asked: <strong>if the program of <em>L&rsquo;Avenir en commun</em> (“A Shared Future,” the platform of La France insoumise) had been in force for two presidential terms, what would it have changed on an episode like this one?</strong></p>
<p><strong>How you grade a model on the past</strong>—three terms that will come up all the way through. The <strong>hindcast</strong> (retrospective replay): you place yourself at a past instant, give the model only what was known at that moment, simulate forward, and compare against what actually happened. The <strong>area bias</strong>: the ratio of predicted burned area to observed burned area—a bias of 7.5 means the model burns seven and a half times too much. The <strong>AUC</strong>: draw at random one cell that burned and one that did not; the AUC is the probability that the model gave the higher score to the first. At 0.95, it is rarely wrong about the <em>where</em>—even when it is badly wrong about the <em>how much</em>.</p>
<hr />
<h3 id="six-days-forty-five-thousand-hectares">Six days, forty-five thousand hectares</h3>
<p>The facts first, because they set the scale. The main fire did not start at Biscarrosse: it started on <strong>July 22</strong> at Saumos, in Gironde, from a brush-clearing worksite—1,400 hectares (ha) by that same evening. The Biscarrosse fire, a van in flames, followed the next day. From there, the days are counted in tens of thousands of hectares:</p>
<ul>
<li><strong>July 24</strong>—19,000 ha in Gironde, 110,000 people evacuated, including the Cap Ferret peninsula by land <em>and by sea</em>;</li>
<li><strong>July 25</strong>—more than 32,000 ha, 167,000 evacuees, first drop of fire retardant—a product that does not evaporate like water but coats the vegetation to make it nonflammable—by a French Air Force A400M;</li>
<li><strong>July 26</strong>—42,000 ha, five towns at the gates of Bordeaux evacuated via FR-Alert (France&rsquo;s cell-broadcast emergency alert system): roughly <strong>220,000 people displaced in total</strong>, one of the largest peacetime evacuations in French history;</li>
<li><strong>July 27–28</strong>—a lull, firebreaks widened by bulldozer, the fire “contained but not fixed,” then “stabilized”&hellip; and a new heat wave announced by <em>Météo-France</em> (the national weather service).</li>
</ul>
<p><a href="https://pf.olibrio.fr/images/kokusho/chronologie-2026.svg" title="Open the chart full size"><img alt="Bar chart of the total area burned, day by day from July 22 to 28, 2026: 1,400 hectares on the 22nd, 4,800 on the 23rd, 21,600 on the 24th with the evacuation of Cap Ferret by land and sea, 35,500 on the 25th with the first A400M drop, 45,500 on the 26th with FR-Alert and about 220,000 evacuees in total, then stabilization on the 27th and 28th, under the announcement of a new heat wave" src="https://pf.olibrio.fr/images/kokusho/chronologie-2026.svg" /></a></p>
<p>Click on the figures to open them full size.</p>
<p>At the last tally I could source: 42,000 ha in Gironde, 3,500 in the Landes, 88 firefighters injured, more than 2,750 firefighters and nearly 3,000 gendarmes and soldiers deployed, European reinforcements (Croatian Canadairs, Portuguese Air Tractors, Czech and Slovak heavy helicopters), a Reaper drone for thermal mapping. EDF stated that the Blayais nuclear plant, some fifty kilometers from the flames, is not under threat—so the question that launched this whole project has, for now, a reassuring answer. The CEA&rsquo;s Laser Mégajoule facility, for its part, saw firebreaks dug in a hurry around its site.</p>
<p>📡 On the tool side: the map at <a href="https://kokusho.ss2i.ca">kokusho.ss2i.ca</a> is still tracking both fires. This morning, the model moved the Cazaux air base to heightened watch against the secondary fire—the observed front is 7.3 km away and a third of the <em>no-suppression</em> scenarios reach the 6 km zone within 48 hours. In fairness: no press source documents any threat to Cazaux; this is a model output, and a <em>counterfactual</em> one. That is exactly the distinction this post is going to spend its time drawing.</p>
<hr />
<h3 id="what-worked-in-the-model">What worked in the model</h3>
<p>Since the first post, the model has passed two tests and gone up a level.</p>
<p><strong>First test: the fuel-exhaustion hypothesis.</strong> The over-prediction of July 25 (a factor of 12) had a rival explanation to the firefighting effort: maybe the fire was simply boxed in between the ocean, the lakes and its own scar, with nothing left to burn. The test is simple and I recommend it to every modeler: look at <em>where</em> the model burns its excess hectares. The verdict is unambiguous—<strong>94% of the predicted area falls on intact pine forest</strong> that the real fire never touched. The fuel was there. The fire did not take it. What stopped it was indeed the firefighting effort, and it had to be modeled.</p>
<p><strong>Second test: two attempts at a suppression model, two instructive failures.</strong> The first drew lots, cell by cell, on whether the crews held: they would have needed to win 93% of the skirmishes; they were winning 15%. The second held entire stretches of track—some thirty kilometers held&hellip; which the fire calmly went around. The lesson deserves to be written in bold: <strong>you do not contain a wildfire by holding a lot of line, you contain it by holding a closed loop</strong>, anchored on barriers the fire will not cross. Containment is a problem of topology, not of local physics.</p>
<p><strong>The level gained: modeling the incident commander&rsquo;s reasoning.</strong> Finding the best closed loop around a fire, leaning as much as possible on water, already-burned ground and existing roads, is a problem mathematics knows how to solve exactly, and the idea can be told without an equation. Every path leading from the fire to the outside must cross the line of defense—otherwise the loop is not closed, and the fire will find the hole. Looking for the best loop therefore means looking for the <em>cheapest-to-hold</em> set of cells that cuts <em>every</em> path: this is what is called a <strong>minimum cut</strong>. A classic theorem—<a href="https://doi.org/10.4153/CJM-1956-045-5">Ford and Fulkerson, 1956</a>—says this cut can be computed by solving a flow problem: imagine trying to push a fluid from the fire toward the outside, with each cell letting through a flow rate equal to its defense cost; the maximum flow that manages to get through is <em>exactly</em> the price of the best barrier, and the cells that saturate <em>are</em> the barrier. This is what Dinitz&rsquo;s algorithm computes, as implemented in <a href="https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.csgraph.maximum_flow.html">SciPy</a>. Water is free, a track costs half price, open pine forest costs full fare—and every kilometer is billed according to the probability that a crew can actually hold it, based on documented operational thresholds: below 2,000 kW per meter of front, direct attack is possible; above 10,000, <em>nothing holds</em>, aircraft included.</p>
<p><strong>Byram&rsquo;s fireline intensity</strong> (Byram, 1959): the power released per linear meter of front, in kW/m—the product of the vegetation&rsquo;s heat of combustion, the mass consumed per square meter and the front&rsquo;s rate of advance. It is the quantity that decides everything: a Landes pine forest running at 600 m/h releases about 6,000 kW/m—already beyond the reach of a direct ground attack. Flame height follows from it almost directly (about 4 m here).</p>
<p><strong>Two implementation details</strong>, for anyone who wants to redo the calculation. First, flow algorithms put capacities on the <em>links</em> between cells, whereas here it is the cell itself one is buying: so each cell is split into an “in” node and an “out” node connected by an internal arc priced at the cell&rsquo;s cost—the technique known as <em>node splitting</em> (Ahuja, Magnanti &amp; Orlin, <em>Network Flows</em>, 1993, § 2.4). Second, the cut must separate cells counting their <em>eight</em> neighbors, diagonals included—the “Moore neighborhood” of cellular automata—because fire spreads diagonally too: a barrier that only blocks the four cardinal neighbors lets the fire slip between two cells set in a staggered pattern.</p>
<p><a href="https://pf.olibrio.fr/images/kokusho/coupe-minimale.svg" title="Open the diagram full size"><img alt="Schematic diagram of containment as a minimum cut. At the center, the scar already burned and the active front, surrounded by a retreat band conceded in advance. The defense loop leans on a lake to the west, at zero cost, follows a DFCI track to the south, at half price, and crosses pine forest elsewhere, at full fare. To the northeast, a breach: an arc lost or never funded, through which a tongue of fire escapes" src="https://pf.olibrio.fr/images/kokusho/coupe-minimale.svg" /></a></p>
<p>The commander&rsquo;s loop, in principle: every kind of terrain has its price, and the cheapest cut that closes the circle <em>is</em> the strategy.</p>
<p>Tuning it was a lesson in humility by iteration, each one paid for with a hindcast of some twenty minutes:</p>
<ul>
<li>without a price on the area conceded, the “optimal” cut went looking for lakes thirty kilometers away and gave up 150,000 hectares—mathematically minimal, operationally absurd;</li>
<li>a line held at 3 a.m. is not held at 3 p.m.: I had to add <em>overrun</em>, the re-testing of lines when the front intensifies sharply—the midday surge that breaks the lines established overnight;</li>
<li>and resources are finite: the defense is always attempted, but units wear out arc by arc, and the funding gaps become the breaches through which the big fires spill over.</li>
</ul>
<p><a href="https://pf.olibrio.fr/images/kokusho/boucle-25-juillet.png" title="Open the map full size"><img alt="Map generated by the model: the containment loop computed by the minimum cut on the July 25 hindcast at 4 a.m. Over an OpenStreetMap land-cover background, the large gray-brown fire scar stretches between the ocean to the west and the lakes; the orange loop of arcs to hold, 187 kilometers for 159 units, hugs it closely, completed by black segments where the ocean, the lakes and the scar close the circle for free. To the east, several small rings encircle the satellite fires" src="https://pf.olibrio.fr/images/kokusho/boucle-25-juillet.png" /></a></p>
<p>And the same loop for real: the plan computed by the minimum cut on the July 25 fire, anchored on the ocean, the lakes and the scar. Each orange islet to the east rings a secondary fire detected away from the main one.</p>
<p>The result, on the two reference days: on the contained day, the bias falls from 12 to 7.5—better, not good; the remainder is split between the nooks of the scar the satellite never saw burn, the spot fires the model does not yet fight, and fuel moisture, still computed by an instantaneous formula with no memory (Simard, 1968): it looks only at the hour&rsquo;s temperature and humidity, so that an afternoon at 39.8 °C can appear <em>wetter</em> than the day before. The known remedy is the Canadian Forest Fire Weather Index (Van Wagner, 1987) and its cumulative components DMC and DC—two “reservoirs” that fill with every rain and drain day after day with the heat, thereby keeping the memory of weeks of accumulated drought. That is the next job. On the day the fire escaped, on the other hand, the model now predicts a mean area <strong>within 14%</strong> of the observed, and it knows <em>why</em>: the lines held in the morning give way at the thermal peak. In both cases the AUC stays above 0.95—the model still knows where the fire is going.</p>
<hr />
<h3 id="the-experiment-a-model-makes-possible-varying-the-resources">The experiment a model makes possible: varying the resources</h3>
<p>And this is where modeling becomes a political instrument—in the noble sense. Version 3 parameterizes the firefighting effort in “units deployed,” one unit holding about one kilometer of line, with the headcount drawn at random around a mean calibrated on real mobilizations (2,000 to 2,750 firefighters on the great Gironde fires of 2022 and 2026). Nothing then stops you from turning the dial: <strong>what happens with 30% fewer resources? With 50% more?</strong></p>
<p>I replayed the two reference days at three levels of deployment, twenty draws each:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Resources committed</th>
<th>July 25 (holdable front) &mdash; median new area burned</th>
<th>July 24 (unholdable front) &mdash; median new area burned</th>
</tr>
</thead>
<tbody>
<tr>
<td>−70 % (“three megafires at once”)</td>
<td>22,067 ha</td>
<td>8,244 ha</td>
</tr>
<tr>
<td>−30 % (“austerity continues”)</td>
<td>18,352 ha</td>
<td>6,154 ha</td>
</tr>
<tr>
<td>Nominal (actual mobilization)</td>
<td>18,324 ha</td>
<td>6,154 ha</td>
</tr>
<tr>
<td>+50 % (“the public service rebuilt”)</td>
<td>18,324 ha</td>
<td>6,154 ha</td>
</tr>
</tbody>
</table></div>
<p>Hindcasts at +12 h, 20 draws per deployment level. “Median”: half the draws burn more, the other half less—more robust than a mean against extreme draws. Observed: 1,645 ha on the 25th, 4,642 ha on the 24th. Orders of magnitude from an experimental model, not forecasts.</p>
<p>I was expecting a nice downward curve—more resources, fewer hectares. The model answered something else, and it is the most interesting result of the week: <strong>resources do not buy hectares, they buy a loop</strong>. Below the threshold where the loop stops being affordable—here, around −70%, the equivalent of a force spread across three simultaneous megafires, which is exactly the fear for the summers ahead—you lose thousands of hectares at once. Above the threshold, the marginal return collapses: between nominal and +50%, <em>not one hectare of difference</em>. And on July 24, the whole column says it: line up 34 units or 168, the fire gets through—the lines give way to intensity, not to numbers.</p>
<p><a href="https://pf.olibrio.fr/images/kokusho/sensibilite-moyens.svg" title="Open the chart full size"><img alt="Two side-by-side curves, on the same scale, of new area burned in twelve hours as a function of the resources deployed. July 25, holdable front: 22,067 hectares at minus 70 percent, then a plateau at about 18,300 from minus 30 percent onward—the threshold where the loop becomes affordable, then saturation. July 24, unholdable front: 8,244 then 6,154 hectares, the curve no longer moves whatever the level of resources. In dashed blue, the observed: 1,645 and 4,642 hectares" src="https://pf.olibrio.fr/images/kokusho/sensibilite-moyens.svg" /></a></p>
<p>The shape of the curve is the result: a threshold, then saturation—and on July 24, a curve that resources no longer move.</p>
<p>An important caveat before drawing political conclusions from it: this table understates the real effect of resources, because the model represents neither <em>initial attack</em>—smothering an ignition within the first hour, where every available Canadair counts double—nor the speed of aerial rotations, nor the splitting of the force across several fires. What it establishes is the shape of the curve: a threshold, then saturation. Not the uselessness of resources.</p>
<hr />
<h3 id="the-other-army-the-logistics-that-crack-and-the-people-who-carry-them">The other army: the logistics that crack, and the people who carry them</h3>
<p>There is one thing that neither the table nor the curve shows, and that leaps out as soon as you read the situation reports and the local press: <strong>the question was not only how many units, but how they stayed on their feet</strong>. Logistics—water, vehicles, aircraft, feeding the crews—was the weak link from end to end, and it was cracking before the episode even began.</p>
<p>The facts, with dates. During the fires, <strong>seven Canadairs out of twelve</strong> were operational, the rest grounded for maintenance on thirty-year-old airframes—and the order for two additional aircraft had been canceled in 2024, before being placed again in June 2026 for delivery&hellip; in 2032. The national fleet of wildland fire engines went from <strong>5,117 in 2002 to 3,845 in 2020</strong>, with the share of aging vehicles rising from 51 to 61%; the firefighters&rsquo; federation estimates that 10,000 would be needed. Three weeks before the episode, on July 6, a union of professional firefighters warned that <strong>more than 300 vehicles</strong> were missing to cover the needs of a season already at 7,000 fire starts. On the ground, a battalion chief was asking for a 13,000-liter tanker to “keep a permanent water supply,” reinforcement columns were arriving from the Paris region, and a volunteer firefighter from Bouches-du-Rhône died on the road—<em>on a resupply mission</em>. A chief warrant officer put it bluntly in the press: “we don&rsquo;t have enough resources, human or material, to respond to disasters of such a scale.” These are not accidents: the <em>Cour des comptes</em> (France&rsquo;s supreme audit institution) spoke in 2022 of a “failure of anticipation” on the aerial fleet, the Senate (2022) of “chronic under-investment” in fire engines, and the firefighters&rsquo; federations of a “model running out of breath.” Four years later, the 2026 episode played out with those very failures—known, written down, and uncorrected.</p>
<p>And yet the lines held on the days that were holdable. Partly because a second army rose up, one that nobody budgets for. On July 24 at 7 a.m., the <em>préfecture</em> of the Landes (the local office of the national government, headed by the <em>préfet</em>) officially issued “an appeal for solidarity to farmers to bring water tankers”—those trailer tanks holding tens of thousands of liters that are towed behind a tractor, and that refill the fire engines as close as possible to the front. Within hours, the Gironde chamber of agriculture registered <strong>more than 500 volunteers</strong>—Médoc winegrowers, wine merchants with their tanker trucks, public-works contractors with stubble cultivators, blades and bulldozers—of whom about a hundred would actually work the Saumos fire, to the point that the appeal was <em>suspended</em> on the 27th, overwhelmed by the influx. The timber industry felled <strong>22 kilometers of pines in twenty-four hours</strong> to open firebreaks—103 km of firebreaks and access tracks created in total, says the prefectoral report of the 27th. Behind the front: volunteers running the supply centers for the 2,750 firefighters, restaurateurs preparing thousands of meals, veterinarians treating evacuated animals for free, the Bordeaux-Lac exhibition center opened for 10,000 people. None of this was improvised from scratch: back in 2022, at Landiras, farmers&rsquo; slurry tankers had saved the firefighters “up to three hours a day” according to the chamber of agriculture—and it was precisely this experience that Gironde had begun to formalize, a month before the episode, by taking inventory of the farm equipment that could be mobilized.</p>
<p>Let us reread that through the model&rsquo;s lens, because it sheds light on both. The model counts “units that hold a kilometer” without asking who crews them: in reality, some of those units were tractors. The water resupply, the three hours a day of 2022, the plowed firebreaks—in the model&rsquo;s vocabulary, that is <em>endurance</em>: arcs that stay held instead of giving way for lack of water, units that stay on the line instead of shuttling back and forth. In other words, <strong>the margin below the threshold, the margin the curve shows decides everything, was partly supplied by society itself</strong>—at the very moment when the State, which had had the reports pricing out what was missing sitting on its desk for four years, did not have it. Both halves of that sentence need saying: the mutual aid was magnificent, and it is the symptom of a shortfall. A slurry tanker does not replace a Canadair in maintenance; five hundred volunteers in twenty-four hours cannot be ordered, cannot be planned, and get suspended when the influx overwhelms coordination; and a system that structurally needs its neighbors&rsquo; tractors to hold its lines is not a properly sized system—which is exactly what the parliamentary report on the “value of what is saved” (the <em>valeur du sauvé</em>), presented ten days before the episode, was trying to say in budgetary language.</p>
<hr />
<h3 id="what-if-lavenir-en-commun-had-been-in-force-for-two-terms">What if L&rsquo;Avenir en commun had been in force for two terms?</h3>
<p>Let&rsquo;s get to it. The question was put to me this way: would the measures proposed by La France insoumise, applied since 2017, have changed anything about an episode like this one? The exercise is a counterfactual: it cannot be verified, it can only be argued. So I will start with what the program <em>actually</em> contains, because the first surprise is there.</p>
<p><strong>What the program contains—and does not contain.</strong> I looked. Neither the 2017 edition nor the 2022 edition of L&rsquo;Avenir en commun includes a chapter on civil protection. No number of firefighters to recruit, no budget for the <em>SDIS</em> (the <em>services départementaux d&rsquo;incendie et de secours</em>, France&rsquo;s county-level fire and rescue services), no number of Canadairs. The measures that touch our subject are elsewhere:</p>
<ul>
<li><strong>section 27</strong> (“Defending the forest”): strengthen the resources and staffing of the <em>ONF</em> (the <em>Office national des forêts</em>, the national forestry agency), ban clear-cutting—felling an entire parcel in one go—promote forests <em>diversified in species and age</em> against monocultures, and “strengthen the means of wildfire prevention and suppression”—wording without a figure;</li>
<li>the <strong>Forest policy booklet</strong>: doubling the ONF&rsquo;s staff (about 8,400 agents today, 16,000 in the mid-1980s), public acquisition of 100,000 ha, firefighting resources deemed “notoriously insufficient”;</li>
<li><strong>section 79</strong>: a Mediterranean intervention and civil-protection force against wildfires—the most directly “fire”-related measure in the program;</li>
<li><strong>section 68</strong>: a nine-month citizen conscription including civil-protection missions, and a national guard under civilian command.</li>
</ul>
<p><strong>In Parliament, on the other hand, the figures exist.</strong> I went through the archives of the last two legislatures (2022–2026), and the picture is more precise—and more modest—than a slogan in either direction would suggest:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Proposal</th>
<th>Figure</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
<tr>
<td>Structural investment in the SDIS (amendment II-CF1334, PLF 2023 budget bill, F. Chauche, special rapporteur)</td>
<td>€15M/year for 5 years, i.e. €75M</td>
<td>Rejected in committee, October 2022</td>
</tr>
<tr>
<td>Permanent SDIS funding through the tax on insurance contracts (I-CF594, PLF 2025 budget bill)</td>
<td>≈ +10% of the total SDIS budget</td>
<td>Not adopted</td>
</tr>
<tr>
<td>“Civil protection” share of the tourist tax allocated to the SDIS (PLF 2026 budget bill)</td>
<td>€0.10 to €0.50 per overnight stay</td>
<td>Not adopted</td>
</tr>
<tr>
<td>Funding of the two planned Canadairs at 100% by the EU instead of 90% (CL265, LOPMI 2022)</td>
<td>2 aircraft, 10% of the cost</td>
<td>Not adopted</td>
</tr>
<tr>
<td>Fleet of sixteen Canadairs—<em>a 2022 government target, whose execution LFI is demanding</em> (written question by C. Lejeune, July 2025)</td>
<td>12 → 16 aircraft</td>
<td>Not achieved (deliveries 2028–2033)</td>
</tr>
<tr>
<td>Alternative French water bombers while waiting for the DHC-515s (Maudet&rsquo;s work, PLF 2026 budget bill)</td>
<td>≈ €30M per unit</td>
<td>Exploratory</td>
</tr>
</tbody>
</table></div>
<p>Amendments and questions from the LFI group, 16th and 17th legislatures. Links at the end of the article. The figure of 220,000 volunteer firefighters by 2027 that one sometimes comes across is a <em>government</em> target, which LFI supported and questioned—not an LFI proposal.</p>
<p>Two honest observations about this table. First: the phrase “LFI proposed so many firefighters and so many Canadairs” would be <strong>misleading</strong>—the archives show funding mechanisms (€75M of investment, ~+10% in permanent resources, the “value of what is saved” from the Maudet–Pantel report presented ten days before the episode) and a demand that existing government targets be met, not headcount or fleet targets of its own. Second: everything was rejected, withdrawn or left without follow-up, the budgets then being pushed through under Article 49.3 (the constitutional provision that lets the government pass a bill without a vote). Against that, the reality: ONF staffing has shrunk by <strong>40% in twenty years</strong>, the French fleet counts twelve Canadairs over thirty years old, the <em>Cour des comptes</em> was denouncing a “failure of anticipation” in their renewal as early as July 2022—and the two aircraft ordered in June 2026 will be delivered <em>in 2032</em>.</p>
<p><strong>Now let&rsquo;s put the model to work.</strong> In kokusho, a public policy can only act through three channels, and they do not carry anything like the same weight:</p>
<p><em>Channel 1—the available units.</em> More firefighters, more engines, an aerial fleet of sixteen aircraft instead of twelve: in the model, that is the mean deployment going up. And here, setting this post&rsquo;s two tables side by side is instructive: the real parliamentary proposals—+10% of the SDIS budget, €75M of investment—are of an order of magnitude that, mapped onto my sensitivity curve, falls <em>in the saturation zone</em>. On a holdable day, with the whole national force massed on a single fire, +10% would not have changed the burned area. Their value lies elsewhere, and it is twofold: <strong>margin below the threshold</strong> for the day when resources are spread thin—three simultaneous ignitions, half the fleet grounded, columns eight hours&rsquo; drive away—and <strong>equipment that gets moving faster</strong> on new ignitions, the channel my model does not yet represent and where every available aircraft counts double. That is precisely the ground of the sixteen Canadairs and the €30M supplementary aircraft: not winning the battle of July 24, but making sure it never takes place.</p>
<p><em>Channel 2—the width of the firebreaks.</em> The <em>DFCI</em>—<em>défense de la forêt contre l&rsquo;incendie</em>, forest defense against fire—is the network of sandy tracks, firebreaks and water points that grids the Landes forest precisely to give the crews ready-made lines to lean on. Maintained, cleared of brush, widened—the work of the ONF and of funded local governments—in the model, that means wider breaks, hence higher holding probabilities at equal intensity. This is the channel of the 40% of ONF staff lost: maintaining the network is precisely the work that was stripped away. Hard to quantify, but the mechanism is the right one.</p>
<p><em>Channel 3—the fuel itself.</em> And this is the decisive channel, because it is the only one that acts on <strong>intensity</strong>. The Landes forest is a million-hectare monoculture of maritime pine—our fuel map is monochrome, and that monochromy is a public policy one hundred and seventy years old. A forest diversified in species and age, broken up by less flammable broadleaves, means a lower front intensity; and intensity is the only quantity that decides whether a line is holdable. Below 2,000 kW/m, everyone holds. Above 10,000, no one does. <strong>The slow measures are the only ones that move the thresholds.</strong> But let&rsquo;s be honest about the horizon: two terms do not transform an industrial pine plantation—ten years of diversification means edges, test parcels, better-kept interfaces. The effect in 2026 would have been marginal; the effect in 2046 would be structural.</p>
<p><strong>And July 24?</strong> That is the question that separates an honest counterfactual from a dishonest one. The model&rsquo;s answer: on that day, none of the above was enough. With 50% more resources, the simulated area for the 24th does not move by a single hectare: the lines give way to intensity, not to numbers. When the front exceeds 10,000 kW/m, the arithmetic of resources no longer applies—you do not bulldoze in the face of a firestorm. The only possible victory on the 24th lay <em>upstream</em>: the fire never reaching that size. Initial attack within the first hour—which the aging aerial fleet does less and less quickly—and prevention, so that an accidental ignition from a brush-clearing worksite does not find 40 °C, an east wind and an unbroken pine forest. In other words: on the extreme days, only <em>pre-fire</em> policy exists. And the extreme days are precisely the ones that make megafires.</p>
<p><strong>The limits of the exercise, without which it is worth nothing:</strong> the program itself puts no figure on firefighters or aircraft, and the real parliamentary figures (€75M, +10%) are far more modest than the ±50% of my table, which remains a reading hypothesis; the model is experimental and its residual bias is documented; the heat wave, the wind and a century and a half of monoculture cannot be governed in two terms; and a counterfactual can never be verified. What the exercise establishes is more modest and more solid: <em>through which channels</em> a policy acts on a megafire, and which of those channels were saturated on July 24.</p>
<hr />
<h3 id="what-i-take-away-from-it">What I take away from it</h3>
<p>The first post ended on a modeling lesson: the model was not answering the question I thought I was asking it. This one ends on the symmetrical lesson, on the political side: <strong>“more resources” is not a single answer, because “the fire” is not a single problem.</strong> There are the holdable days, where every additional unit buys hectares—there, the Canadairs of 2032 ordered in 2026 are a scandal of budgetary arithmetic. And there are the unholdable days, where the only policy that still exists was played out ten years earlier, in the composition of the forest, the maintenance of the firebreaks, and the speed of initial attack on new ignitions.</p>
<p>A political program that wants to weigh on megafires is therefore judged against both regimes at once. On the first, the program gives the direction and it is in Parliament that the figures are found: modest—€75 million, +10%—systematically rejected or pushed through under 49.3, but placed at the two spots the model identifies as the real levers of the holdable regime, permanent SDIS funding and the initial-attack fleet. On the second—the forest, its staffing, its diversity—L&rsquo;Avenir en commun states the problem at the very place where the thresholds move. And one must enter on the ledger what neither the programs nor my model counts: the margin supplied by society itself—the farmers&rsquo; water tankers, the volunteers at the supply centers—which held once again this time, and which no seriously sized State should ever book in its budget as a given.</p>
<p>It has to end with this said plainly: what just happened is not an anomaly, it is a preview. The 2022 Senate report—the very one that priced out the under-investment—wrote it in black and white: by 2050, burned areas could increase by 80%, and nearly half of mainland France&rsquo;s heathland and forest could be exposed to high wildfire risk. Four years after Landiras, the same forest burned again—bigger, faster, closer to Bordeaux. Two “fires of the century” in four years do not make an exception: they make a regime. <strong>This is the new normal</strong>, and its signature is precisely the more worrying of my curve&rsquo;s two columns: the unholdable days of the July 24 kind, the ones where the arithmetic of resources no longer applies, will be more numerous every summer.</p>
<p>You know the scene: the cat at the edge of the table, paw on the glass, eyes locked on yours—and it pushes. We laugh at the cat. We should look at the observer: the one who watches the glass slide and does not move is not a witness, he is a co-author of the breakage. For four years now that glass has been sliding before all our eyes. The reports were on the table—the <em>Cour des comptes</em> in 2022, the Senate in 2022, the firefighters&rsquo; federations year after year. The amendments were filed—€75 million for fire engines in 2022, <em>rejected</em>; the Canadair appropriations, <em>rejected then pushed through under 49.3</em>; the aircraft order, <em>canceled in 2024</em> only to be placed again in 2026, delivery 2032. The job cuts at the ONF <em>continue in 2026</em>, the very summer the forest burns. At this point, the word “failures” is too gentle: these are choices, made knowingly, by people who had the numbers. And this post has spent enough time measuring to have earned the right to say it: I will not be the observer who says nothing to the cat.</p>
<p>And don&rsquo;t take my word for it: in the middle of the episode, the independent outlet <a href="https://bonpote.com/quels-partis-politiques-ont-vote-pour-la-lutte-anti-incendie/">Bon Pote redid the count, roll call by roll call</a>, of the National Assembly&rsquo;s votes on wildfire suppression. The picture is unambiguous: two blocs. On one side, Communists, La France insoumise, Greens and Socialists, who vote for resources and prevention; on the other, from the president&rsquo;s party to the Rassemblement national, who vote the opposite—the tourist tax for the SDIS, rejected twice; the insurers&rsquo; contribution, rejected when the amendment expected “at least 800 million euros” from it; the program to recruit professional firefighters, voted for by the left alone along with LIOT (a small independent group); and the decree signed by Gabriel Attal in 2024 canceling €50 million in civil-protection appropriations, taking the order for two Canadairs with it. The only unanimity in the whole file: €3 million for the DFCI—which gives the measure of what consensus amounts to. And the measure that matches my model&rsquo;s fuel channel feature for feature—firebreaks of <em>broadleaves</em> between parcels of conifers, to break the continuity of the pine forest—was voted for only by La France insoumise, the Socialists and the Greens. The thresholds this post talks about have a political color in Parliament, and it can be checked vote by vote.</p>
<p>So yes, this post takes a side. From everything I have read and cross-checked, L&rsquo;Avenir en commun is today the <strong>only</strong> national program that answers both regimes at once—and the roll calls above show that when these measures reach the floor, it is the whole of the left that votes for them: fuel and forest—species diversification, an end to clear-cutting, ONF staffing doubled, where the thresholds that decide whether a line is holdable move—and the sizing of civil protection—permanent SDIS funding through the “value of what is saved,” an initial-attack fleet now and not in 2033. Let no one tell me its figures are missing: they were filed, amendment after amendment, under the numbers this post cites—and it is those who rejected them who owe an accounting today. The rest of the political offer, when it talks about fire at all, talks only about aircraft—the holdable regime—and never about fuel—the regime that kills. But the Canadairs of 2033 will change nothing on the days when nothing flies, and those are the days that make megafires. <strong>A program that does not talk about the forest is not talking about the problem.</strong> The farmers&rsquo; tractors held the line this time. Next time—and the new normal guarantees there will be one—I would like the Republic to get there before they do.</p>
<p>The fire, for its part, does not wait for the debate to end: Météo-France is announcing a new heat wave. The map keeps running.</p>
<hr />
<p>🔥 <strong><a href="https://kokusho.ss2i.ca">See the tool online—kokusho.ss2i.ca</a></strong></p>
<p>Experimental, with no official standing. In the event of a fire, the only authoritative source is the <em>préfecture</em>: <a href="https://www.landes.gouv.fr/">landes.gouv.fr</a> · <a href="https://www.gironde.gouv.fr/">gironde.gouv.fr</a>.</p>
<h3 id="sources">Sources</h3>
<ul>
<li>Timeline and tally: <a href="https://fr.wikipedia.org/wiki/Feux_de_for%C3%AAt_de_2026_en_Gironde_et_dans_les_Landes">Wikipedia (French), 2026 wildfires in Gironde and the Landes</a>; press releases from the <a href="https://www.gironde.gouv.fr/Actualites/Communiques-de-presse/Communiques-de-presse-2026/Juillet-2026/Incendie-en-Gironde-declenchement-de-FR-Alert-pour-l-evacuation-des-nouvelles-communes">Gironde préfecture</a> and the <a href="https://www.landes.gouv.fr/Actualites/Salle-de-presse/Communiques-de-presse/2026/Incendie-en-cours-a-Biscarrosse-point-de-situation-a-07h">Landes préfecture</a>; situation reports from <a href="https://www.franceinfo.fr/environnement/evenements-meteorologiques-extremes/incendies-et-feux-de-foret/incendies-en-gironde/incendies-en-gironde-et-dans-les-landes-le-point-sur-la-journee-du-lundi-27-juillet_8124539.html">franceinfo (July 27)</a> and <a href="https://www.franceinfo.fr/replay-jt/france-2/20-heures/incendie-en-gironde-des-prochaines-heures-decisives_8124449.html">France 2 (July 28)</a>.</li>
<li>Blayais: <a href="https://montelnews.com/fr/news/964487e1-b5d4-4dc1-a05c-134cc576314b/feux-en-gironde-pas-d-impact-sur-les-re-acteurs-du-blayais">Montel News, July 27, 2026</a>. Strategic sites: <a href="https://armees.com/incendie-gironde-sites-surveillance/">armees.com</a>.</li>
<li>Program: <a href="https://laec.fr/section/27/defendre-la-foret-poumon-de-la-planete">L&rsquo;Avenir en commun, section 27</a>; <a href="https://melenchon2027.fr/livrets-2022/foret/">Forest booklet</a>; sections 68 and 79 on <a href="https://laec.fr/">laec.fr</a>.</li>
<li>The National Assembly&rsquo;s votes, roll call by roll call: <a href="https://bonpote.com/quels-partis-politiques-ont-vote-pour-la-lutte-anti-incendie/">Bon Pote, “Which political parties voted for wildfire suppression?”, Sophie Kloetzli, July 27, 2026</a>—tourist tax for the SDIS (roll calls 302 and 4074, 17th legislature), insurers&rsquo; contribution (4076), recruitment of professional firefighters (133, 16th legislature), forest adaptation plan (1509), broadleaf firebreaks between conifers (1556), decree canceling €50M in civil-protection appropriations (2024).</li>
<li>Parliament and budget: amendments <a href="https://www.assemblee-nationale.fr/dyn/16/amendements/0273C/CION_FIN/CF1334">II-CF1334 (PLF 2023, €75M SDIS)</a>, <a href="https://www.assemblee-nationale.fr/dyn/17/amendements/0324A/CION_FIN/CF594">I-CF594 (PLF 2025, TSCA ≈ +10% SDIS)</a>, <a href="https://www.assemblee-nationale.fr/dyn/17/amendements/AMANR5L17PO838901B1906P1D1N003166">I-3166 (PLF 2026, tourist tax)</a>, <a href="https://www.assemblee-nationale.fr/dyn/16/amendements/0343/CION_LOIS/CL265">CL265 (LOPMI 2022, Canadairs 100% EU)</a>, <a href="https://www.assemblee-nationale.fr/dyn/17/amendements/1906C/CION_FIN/CF2221">II-CF2221 (PLF 2026, supplementary aircraft ≈ €30M)</a>; written questions <a href="https://questions.assemblee-nationale.fr/q16/16-2391QE.htm">no. 2391 (F. Chauche, volunteers)</a> and <a href="https://questions.assemblee-nationale.fr/q17/17-9156QE.htm">no. 9156 (C. Lejeune, sixteen Canadairs)</a>; <a href="https://nosparlementaires.fr/actualites/canadair-securite-civile-ce-que-le-parlement-a-vote">nosparlementaires.fr, “Canadair: what Parliament voted”</a>; <a href="https://www.lagazettedescommunes.com/club-prevention-securite/securite-civile/sapeurs-pompiers-et-sdis/budget-des-sdis-la-valeur-du-sauve-fait-recette-a-lassemblee-nationale.MNCLJM6YOFHRZEW765AU42AXUE.html">La Gazette des communes, the “value of what is saved” report</a>.</li>
<li>Context: <a href="https://basta.media/face-aux-incendies-l-onf-en-premiere-ligne-malgre-la-baisse-des-effectifs">Basta!, ONF staffing</a>; <a href="https://www.ccomptes.fr/fr/publications/la-flotte-aerienne-de-la-securite-civile">Cour des comptes, referral of July 26, 2022</a>; <a href="https://www.usinenouvelle.com/aero-spatial/aeronautique/aviation-civile/en-attendant-un-modele-made-in-france-letat-rachete-deux-canadair-supplementaires-au-canadien-de-havilland-pour-200-millions-deuros.TAX5FDHQOZGURDPUXWS7BRRU6E.html">L&rsquo;Usine nouvelle, Canadair order (June 2026)</a>; <a href="https://www.senat.fr/rap/r21-856/r21-856_mono.html">Senate, report no. 856 (2022)</a>.</li>
<li>Logistics and mutual aid, 2026 episode: <a href="https://fr.wikipedia.org/wiki/Feux_de_for%C3%AAt_de_2026_en_Gironde_et_dans_les_Landes">seven Canadairs out of twelve operational (Le Monde, via Wikipedia)</a>; <a href="https://www.landes.gouv.fr/Actualites/Salle-de-presse/Communiques-de-presse/2026/Incendie-en-cours-a-Biscarrosse-point-de-situation-a-07h">prefectoral appeal for water tankers (Landes, July 24, 7 a.m.)</a>; <a href="https://www.vitisphere.com/actualite-107124-100-agriculteurs-intervenus-contre-lincendie-500-volontaires-au-total-la-chambre-dagriculture-suspend-son-appel.html">Vitisphere: 100 farmers deployed, 500 volunteers, appeal suspended</a>; <a href="https://gironde.chambres-agriculture.fr/actualites-33/detail-de-lactualite/feux-en-gironde">coordination by the Gironde chamber of agriculture</a>; <a href="https://www.gironde.gouv.fr/Actualites/Communiques-de-presse/Communiques-de-presse-2026/Juillet-2026/Incendie-de-Saumos-point-de-situation-a-22h30-ce-lundi-27-juillet">prefectoral report of July 27: tally, 103 km of firebreaks</a>; <a href="https://www.franceinfo.fr/faits-divers/incendies-en-gironde/en-gironde-le-ravitaillement-des-pompiers-s-organise_8122952.html">franceinfo: supply run by volunteers</a>; <a href="https://www.franceinfo.fr/environnement/evenements-meteorologiques-extremes/incendies-et-feux-de-foret/on-n-a-pas-suffisamment-de-moyens-ni-humains-ni-materiels-secouristes-et-sinistres-en-colere-face-au-niveau-bilan-des-incendies-en-gironde_8123675.html">franceinfo: “we don&rsquo;t have enough resources”</a>; <a href="https://france3-regions.franceinfo.fr/nouvelle-aquitaine/gironde/arcachon/je-voulais-etre-utile-le-bel-elan-de-solidarite-face-a-l-incendie-en-gironde-et-dans-les-landes-3391921.html">France 3: “I wanted to be useful”</a>; <a href="https://www.bordeaux-metropole.fr/actualites/incendies-en-gironde-accueil-personnes-evacuees-collecte-dons">Bordeaux Métropole: shelter at Bordeaux-Lac, donation drive</a>; <a href="https://cnews.fr/france/2026-07-27/incendies-les-pompiers-recensent-beaucoup-de-points-chauds-en-gironde-une-reprise">CNEWS: a volunteer firefighter killed on a resupply mission</a>; <a href="https://snspp-pats.com/feux-despaces-naturels-le-snspp-pats-alerte-une-nouvelle-fois/">SNSPP-PATS, July 6, 2026: more than 300 vehicles missing</a>; <a href="https://viralmag.fr/incendies-en-gironde-pompiers-en-course-contre-un-feu-imprevisible/">report: “we&rsquo;re chasing the fire,” the 13,000-liter tanker</a>; <a href="https://mesinfos.fr/ile-de-france/incendie-en-gironde-la-colonne-de-renfort-commandee-par-un-essonnien-335962.html">the Paris-region reinforcement column, the 22 km of pines felled in 24 h</a>.</li>
<li>The 2022 precedent and the structural findings: <a href="https://www.reussir.fr/feux-en-gironde-les-agriculteurs-apportent-leur-aide-aux-pompiers">Réussir: the Landiras slurry tankers (50,000 liters brought in)</a>; <a href="https://www.reussir.fr/face-aux-incendies-comment-la-precieuse-aide-des-agriculteurs-sorganise">Réussir: more than 100 tractors and tankers inventoried in Gironde (August 2022)</a>; <a href="https://www.pleinchamp.com/actualite/les-agriculteurs-un-peu-plus-que-des-porteurs-d-eau-dans-la-lutte-contre-les-incendies-de-foret">Pleinchamp: “up to three hours a day” saved, and the 2026 formalization</a>; <a href="https://agriculture.gouv.fr/incendies-en-gironde-le-monde-agricole-se-mobilise-aux-cotes-des-services-de-secours">Ministry of Agriculture, 2022</a>; <a href="https://www.europe1.fr/societe/un-elan-de-solidarite-que-jai-rarement-vu-en-gironde-les-habitants-ravitaillent-les-pompiers-4123813">Europe 1: residents resupply the firefighters (La Teste, 2022)</a>; <a href="https://www.assemblee-nationale.fr/dyn/16/amendements/0273C/CION_FIN/CF1327.pdf">fire engine fleet: 5,117 (2002) → 3,845 (2020), 61% aging, FNSPF target of 10,000 (DGSCGC data cited in a budget annex)</a>; <a href="https://www.senat.fr/rap/l22-115-329-2/l22-115-329-24.html">Senate, PLF 2023: “chronic under-investment,” and the capacity pacts</a>; <a href="https://www.fosis.org/index.php/2022/08/16/un-modele-de-securite-civile-a-bout-de-souffle/">FO-SIS: “a civil-protection model running out of breath”</a>.</li>
<li>Fire physics: Byram G.M. (1959), “Combustion of forest fuels,” in K.P. Davis (ed.), <em>Forest Fire: Control and Use</em>, McGraw-Hill—fireline intensity <em>I = H·w·R</em> and the flame-length relation <em>L = 0.0775·I^(0.46)</em>; <a href="https://www.fs.usda.gov/rm/pubs_int/int_rp115.pdf">Rothermel R.C. (1972), <em>A Mathematical Model for Predicting Fire Spread in Wildland Fuels</em>, USDA Forest Service INT-115</a>—damping by fine-fuel moisture; Simard A.J. (1968), <em>The Estimation of Moisture Content of Fine Fuels</em>, Forest Fire Research Institute, Ottawa, FF-X-14—the instantaneous moisture formula used (and criticized) here; <a href="https://ostrnrcan-dostrncan.canada.ca/entities/publication/9505b1a8-2b34-40f2-a67a-42d1cf2f2b7d">Van Wagner C.E. (1987), <em>Development and Structure of the Canadian Forest Fire Weather Index System</em>, Canadian Forestry Service, Technical Report 35</a>—the DMC/DC indices with memory.</li>
<li>Operational holding thresholds: <a href="https://doi.org/10.1071/WF9960199">Hirsch K.G. &amp; Martell D.L. (1996), “A Review of Initial Attack Fire Crew Productivity and Effectiveness,” <em>International Journal of Wildland Fire</em> 6(4)</a>; <a href="https://doi.org/10.1007/978-3-319-51727-8_52-1">Alexander M.E. &amp; Cruz M.G. (2019), “Fireline Intensity,” in <em>Encyclopedia of Wildfires and Wildland-Urban Interface (WUI) Fires</em>, Springer</a>—direct attack possible below 2,000 kW/m, engines up to ~4,000, nothing holds above 10,000; the rule “a firebreak holds from two flame lengths up” is a rule of thumb of DFCI planning, treated here as a soft transition rather than a sharp threshold.</li>
<li>Containment algorithmics: <a href="https://doi.org/10.4153/CJM-1956-045-5">Ford L.R. &amp; Fulkerson D.R. (1956), “Maximal Flow Through a Network,” <em>Canadian Journal of Mathematics</em> 8</a>—the max-flow/min-cut theorem; <a href="https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.csgraph.maximum_flow.html">scipy.sparse.csgraph.maximum_flow</a> (Dinitz&rsquo;s algorithm); the conversion from node capacities to edge capacities (<em>node splitting</em>) is described in Ahuja R.K., Magnanti T.L. &amp; Orlin J.B. (1993), <em>Network Flows</em>, Prentice Hall, § 2.4; survey of spread models (cellular automata included): <a href="https://doi.org/10.1071/WF06144">Sullivan A.L. (2009), “Wildland surface fire spread modelling, 1990–2007,” <em>IJWF</em> 18, three parts</a>.</li>
<li>Validation scores: Brier G.W. (1950), “Verification of Forecasts Expressed in Terms of Probability,” <em>Monthly Weather Review</em> 78; AUC computed via the Mann-Whitney statistic; reliability diagrams and spatial scores (Sørensen-Dice, Jaccard): Wilks D.S. (2019), <em>Statistical Methods in the Atmospheric Sciences</em>, 4th ed., Elsevier. Comparison against persistence as the null reference is forecasting&rsquo;s classic safeguard.</li>
<li>Model data: <a href="https://firms.modaps.eosdis.nasa.gov/">NASA FIRMS</a>, VIIRS 375 m active fire detections (<a href="https://doi.org/10.1016/j.rse.2013.12.008">Schroeder W. et al. (2014), <em>Remote Sensing of Environment</em> 143</a>); <a href="https://open-meteo.com/">Open-Meteo</a> (Météo-France models, and Copernicus DEM GLO-90 elevation); land cover and roads: <a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors (ODbL)</a> via the Overpass API. Fuel loads and base spread rates per class: assumed orders of magnitude, documented in the code, not a measured inventory.</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>My model was setting the Atlantic on fire</title>
      <link>https://pf.olibrio.fr/en/posts/mon-modele-faisait-bruler-latlantique.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/mon-modele-faisait-bruler-latlantique.html</guid>
      <pubDate>Mon, 27 Jul 2026 12:00:00 +0000</pubDate>
      <description>A wildfire in the Landes — what if it heads for the Blayais nuclear plant? Two days to cobble together a tool that answers, and all it got wrong.</description>
      <category>wildfire</category>
      <category>landes</category>
      <category>simulation</category>
      <category>validation</category>
      <category>ai</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/kokusho/carte-kokusho-grand.jpg" alt="My model was setting the Atlantic on fire"></p>
<p>On Thursday, July 23, a van catches fire on a county road in Biscarrosse. At 3:45 p.m. the flames jump into the forest, and within three days 30,000 people are evacuated. Like everyone else, I follow it on maps of red dots, and a question settles in &mdash; the kind of question you don&rsquo;t quite dare put into words as long as you can&rsquo;t answer it: <strong>what if it heads for the Blayais?</strong></p>
<p>The Blayais is the nuclear power plant sitting on the right bank of the Gironde estuary, some fifty kilometers north of Bordeaux. A map of red dots does not answer that question. It shows where the fire <em>is</em>. Never where it is <em>going</em>.</p>
<p>To get from one to the other, you need a model. I wanted to see how far one could get cobbling together an honest one in two days. The result exists, it runs, and you can look at it here:</p>
<p>👉 <strong><a href="https://kokusho.ss2i.ca">kokusho.ss2i.ca</a></strong> &mdash; the map is public and updates with every new run.</p>
<p>An experimental tool, with no official standing whatsoever. In the event of a wildfire, the only authoritative source is the préfecture (the French state&rsquo;s local authority).</p>
<p>But that is not the interesting part of the story. The interesting part is the list of everything it got wrong &mdash; and above all, what caught each mistake.</p>
<hr />
<h3 id="the-first-decision-is-moral-not-technical">The first decision is moral, not technical</h3>
<p>A tool like this one can display two very different things. The first:</p>
<p><em>“The fire will reach the plant in 17 hours.”</em></p>
<p>It&rsquo;s crisp. It&rsquo;s actionable. And it&rsquo;s a fraud. To write a sentence like that, you would need to know the exact position of the fire front, the wind for the next seventeen hours, the actual dryness of the fuels and the effectiveness of the firefighting effort. I know none of those four things. Nobody does.</p>
<p>The second looks more like this:</p>
<p><em>“Out of 200 simulated scenarios, 6 bring the fire into the extended zone around the plant, at the earliest in 29 hours. The forecast wind runs crosswise to the site&rsquo;s axis. Low confidence: last satellite detection 5 hours ago.”</em></p>
<p>It&rsquo;s markedly less satisfying to read. It&rsquo;s true.</p>
<p>Everything else follows from that choice. The tool never draws a trajectory; it draws <strong>envelopes</strong> &mdash; the established term for those contours that bound the reachable area &mdash; at 6, 12, 24 and 48 hours, in three levels of decreasing plausibility: the area reached in more than one scenario out of two, the one reached in one out of ten, and the extreme envelope crossed only in one scenario out of a hundred, the one where everything goes wrong at once.</p>
<p>And it refuses to boil the situation down to a single number. It gives three, separately: <strong>the threat level</strong>, <strong>the confidence</strong> that judgment deserves, and <strong>the freshness</strong> of the latest observation. Because a “green” computed on four-hour-old data is not good news: it&rsquo;s an information gap. A satellite that sees nothing does not prove the fire has stopped &mdash; it proves that it sees nothing.</p>
<hr />
<h3 id="the-building-blocks-and-what-they-really-do">The building blocks, and what they really do</h3>
<p><a href="https://pf.olibrio.fr/images/kokusho/pipeline.svg" title="Open the diagram at full size"><img alt="Diagram of the processing pipeline. Thermal detections from NASA FIRMS satellites, VIIRS sensors at 375 meters, are grouped by fire and then reduced to the active front seen in the last twelve hours. AROME weather from Météo-France, vegetation from OpenStreetMap and the digital elevation model feed a cellular automaton, a checkerboard of 150-meter cells, replayed two hundred times using the Monte Carlo method. Out of it come the envelopes at 6, 12, 24 and 48 hours, then a threat level accompanied by the confidence and the freshness of the data" src="https://pf.olibrio.fr/images/kokusho/pipeline.svg" /></a></p>
<p>Click on the figures to open them at full size.</p>
<p><strong>Seeing the fire: FIRMS and VIIRS.</strong> NASA distributes, free of charge and in near real time, the thermal detections from its satellites &mdash; the service is called <strong>FIRMS</strong>, and the sensors I use, <strong>VIIRS</strong>, carve the ground into 375-meter pixels. I prefer them to MODIS, which is older and four times coarser.</p>
<p>You have to understand clearly what a FIRMS detection is: <strong>a pixel whose surface temperature is abnormal</strong>. Not a fire perimeter. With delays, with gaps when no satellite passes overhead, and nothing at all when smoke or clouds block the view. That nuance governs the whole rest of this article.</p>
<p><strong>Knowing what drives it: AROME, OSM, DEM.</strong> Three ingredients move a fire forward. Wind first, which weighs far more than the rest: I take it from <strong>AROME</strong>, the fine-mesh model from Météo-France (the French national weather service), and not at a single point but sampled across the whole domain &mdash; a fire of this size spans several grid cells, and the wind can turn locally. Vegetation next, pulled from <strong>OpenStreetMap</strong>: a dry pine forest burns, a pond does not, a vineyard barely. Terrain last, through a <strong>digital elevation model</strong> &mdash; simply a file of altitudes &mdash; because a fire climbs a slope far faster than it descends one.</p>
<p><strong>Moving it forward: the cellular automaton.</strong> The core of the computation carries a learned name for a simple idea.</p>
<p><strong>Cellular automaton</strong> &mdash; you divide space into regular cells, here 150-meter squares, and you set a rule that says how a cell&rsquo;s state depends on its neighbors. You apply the rule everywhere, advance one time step, and start again. Conway&rsquo;s <em>Game of Life</em> is the best-known example.</p>
<p>My rule: a burning cell tries to ignite its eight neighbors, all the faster when the vegetation there is flammable, when the slope rises, and when the wind pushes in that direction. The front spontaneously takes an <strong>elliptical</strong> shape stretched downwind &mdash; which is what firefighters observe in the field, and what models have reproduced since the 1980s.</p>
<p><strong>Accepting that we don&rsquo;t know: Monte Carlo.</strong> This is the point that changes everything.</p>
<p><strong>Monte Carlo method</strong> &mdash; rather than computing once with the best possible values, you redo the computation hundreds of times, drawing at random, each time, the parameters you know poorly. Then you look at the distribution of the results. The name comes from the casino, and the method from the atomic bomb &mdash; Ulam and von Neumann, 1946.</p>
<p>So I run <strong>200 draws</strong>. Each time I perturb what is genuinely uncertain: the wind forecast error &mdash; which grows with lead time and stays correlated over time, because a wrong forecast is wrong for a stretch rather than every other hour &mdash; the error in my own rate-of-spread model, the actual dryness of the fuels, and the occurrence of <strong>spotting</strong>, those embers thrown ahead of the front that leap across a road or a firebreak.</p>
<p>What gets displayed afterward is no longer a forecast but a tally: this cell burned in 150 of my 200 possible worlds, that one in 3. It&rsquo;s the same logic as the cone of uncertainty for hurricane tracks.</p>
<p>One clarification that matters: these frequencies are <em>not</em> probabilities in the actuarial sense. They are frequencies <strong>conditional on my assumptions</strong>. If my assumptions are bad, so are my percentages. Keep that in mind &mdash; the rest of the article talks about practically nothing else.</p>
<hr />
<h3 id="first-result-a-perfectly-motionless-fire">First result: a perfectly motionless fire</h3>
<p>First run on real data. The report comes out: new area burned after 6 hours, 103.5 hectares. After 12 hours: 103.5 hectares. To the tenth of a hectare.</p>
<p>A fire that doesn&rsquo;t gain a single meter in six hours under a 17 km/h wind is wrong. And my 200 draws, supposed to explore different worlds, all gave rigorously the same result &mdash; which is even more suspicious.</p>
<p>The cause came down to a choice of index. When I computed the speed of passage from a cell to its neighbor, I took the speed of the <strong>source</strong> cell, the one already burning. But I mark the whole area already covered as “scar,” with a near-zero rate of spread &mdash; which is correct: an already burned zone does not easily burn again.</p>
<p>Except that the fire necessarily starts from the already burned area. All my source cells were scars, and therefore incapable of transmitting anything. <strong>The fire was a prisoner of its own perimeter.</strong></p>
<p>The fix comes down to one word: take the speed of the <strong>target</strong> cell, the one whose fuel is about to be consumed. It&rsquo;s also more physically accurate &mdash; a fire advances at the speed allowed by what it is attacking, not by what it has already burned. Unexpected bonus: it also fixes the crossing of bodies of water, which a flammable source cell could leap across.</p>
<hr />
<h3 id="then-i-looked-at-the-map-more-closely">Then I looked at the map more closely</h3>
<p>The tool was running, the envelopes spread out nicely, the numbers looked plausible. I looked at them for a while before doing what I should have done from the start: open the input map, the one the model uses, and really look at it.</p>
<p>There was a problem. <strong>The model was setting the sea on fire.</strong></p>
<p>And it was true. Here is the <strong>fuel map</strong> &mdash; the image that tells the automaton, for each cell, how fast the fire can advance there. On the left what my program saw, on the right the reality:</p>
<p><a href="https://pf.olibrio.fr/images/kokusho/mer-avant-apres.png" title="Open the comparison at full size"><img alt="Two fuel maps side by side, over the same area around Biscarrosse. On the left, the Atlantic Ocean is rendered entirely in dark green, the color of pine forest: the model treats it as fuel. On the right, after correction, the ocean appears in blue, clearly separated from the coastline, and one can make out the large lakes of Cazaux-Sanguinet and Biscarrosse-Parentis" src="https://pf.olibrio.fr/images/kokusho/mer-avant-apres.png" /></a></p>
<p>The whole dark-green mass on the left is the Atlantic. Classified as pine forest.</p>
<p>The reason is almost funny. <strong>OpenStreetMap does not map the ocean as an area.</strong> There is no “Atlantic” polygon: contributors have drawn the <strong>coastline</strong> &mdash; in OSM jargon, a way tagged <code>natural=coastline</code> &mdash; that is, a line, not an area. My query, meanwhile, was looking for areas. It dutifully retrieved the lakes, the ponds and the rivers, all polygons, and the open sea matched nothing.</p>
<p>Now, outside any recognized polygon, my program assumes pine forest. That was a deliberate choice: in the Landes forest (the vast maritime-pine plantation covering the southwest of France) the maritime pine dominates, and erring on the side of “it burns” biases the model toward caution. Except that applying that caution to 40% of the computational domain is no longer called caution. On this map, water goes from 3.4% to 40.7% once corrected: <strong>more than a third of my working area was flammable ocean</strong>.</p>
<p>My first fix retrieved the coastline and used it to cut the domain &mdash; perfect on my isolated test. In real conditions, failure: three of the twelve requests to the OpenStreetMap server came back in error, the coastline ended up full of holes, and a coastline with holes cuts nothing. The sea became fuel again.</p>
<p>The right solution lay elsewhere, and it exploits an OpenStreetMap convention: when a coastline is drawn, <strong>land is always on the left of the drawing direction</strong>. So it&rsquo;s enough, for each cell, to find the nearest coastline segment and check which side you fall on &mdash; a simple cross product. Land on the left, sea on the right. A hole in the data now costs only local precision around the hole.</p>
<p>I only knew my first fix had failed because I had taken care to make the program shout. When it could not reconstruct the sea, it wrote in plain words in its report: <em>“coastline present but unable to derive the extent of the sea from it: the open sea is likely to be treated as fuel.”</em> A model that fails silently lies. A model that fails loudly lets itself be repaired.</p>
<hr />
<h3 id="the-awkward-question-does-it-work">The awkward question: does it work?</h3>
<p>At that point, the tool was producing pretty maps. A pretty map has never proven anything.</p>
<p>And I had at hand something to catch it out: the fire had been burning for several days, so its own past was available. Rather than waiting to see whether the next forecasts came true, I could replay the ones we could have made two days earlier and see what they were worth. It&rsquo;s a well-known method, and it has a name: <strong>hindcasting</strong>.</p>
<p><strong>Hindcast</strong> (or <em>retrospective forecast</em>) &mdash; you place yourself at a moment in the past, keep only the data available on that date, simulate forward, then compare with what actually happened. It&rsquo;s the standard way to evaluate a forecasting model when you can&rsquo;t afford to wait.</p>
<p>So I place myself on the morning of July 24, throw away everything observed afterward, simulate twelve hours, and compare with what actually burned. Then I do it again for the 25th. Two runs, a handful of minutes of computation &mdash; and by far the best ratio between effort spent and what it taught me.</p>
<p><a href="https://pf.olibrio.fr/images/kokusho/validation.svg" title="Open the chart at full size"><img alt="Bar chart comparing, on the same scale, the area actually burned in twelve hours and the area produced by the model. On July 24, while the fire was running freely, 4,642 hectares observed versus 10,008 simulated, an area bias of 1.7. On July 25, while firefighters were holding the front, 1,645 hectares observed versus 22,758 simulated, an area bias of 12.3" src="https://pf.olibrio.fr/images/kokusho/validation.svg" /></a></p>
<p>On the morning of the 24th, when the fire was running freely, the model over-predicts by a factor of 1.7. For a spread model, that&rsquo;s respectable &mdash; we&rsquo;re in the right order of magnitude. On the 25th, as firefighters were regaining control of the front: a factor of 12.3.</p>
<p>What&rsquo;s interesting is not that the model is wrong. It&rsquo;s <strong>where the difference between the two days comes from</strong>. The model did not turn bad overnight: same code, same wind, same forest. What changed between the 24th and the 25th was the firefighting effort.</p>
<p>My program doesn&rsquo;t see it. It knows nothing of the crews engaged, nor of the <strong>control lines</strong> &mdash; those breaks firefighters open with bulldozers to stop the front &mdash; nor of the water drops. So it computes, without my having explicitly decided it, <strong>a no-suppression counterfactual</strong>: what the fire would do if nobody stood in its way. On the 25th, physics alone gave 22,758 hectares; the fire covered only 1,645, because people prevented it.</p>
<p>There is a consolation in these figures, and it&rsquo;s an important one. It lies in a measure called the <strong>AUC</strong>.</p>
<p><strong>AUC</strong>, for <em>area under the ROC curve</em> &mdash; measures the quality of a <em>ranking</em>, independently of absolute values. If I pick at random one cell that burned and one that did not, the AUC is the probability that the model ranked the first one higher. 0.5 = a coin toss, 1 = perfect ranking.</p>
<p>My AUC is <strong>0.96 to 0.98</strong> in both cases &mdash; while the area bias, for its part, varies by a factor of seven. In other words: <strong>the model knows very well where the fire is going, and is badly wrong about how much</strong>. That&rsquo;s the best possible failure mode, because a scale error can be calibrated, whereas a direction error condemns the tool.</p>
<p>Which brings me to the least intuitive decision of this whole tinkering. I had a calibration factor ready to go: slow the spread down by just enough to land on July 25. <strong>I did not apply it.</strong> It would carve into the model the assumption that “suppression always succeeds” &mdash; precisely the one you must not rely on in front of a nuclear plant. The tool, moreover, refuses on its own to publish a global factor when its validation cases are too heterogeneous, and explains why rather than averaging two incomparable regimes.</p>
<hr />
<h3 id="what-it-doesnt-do-and-thats-the-most-serious-part">What it doesn&rsquo;t do &mdash; and that&rsquo;s the most serious part</h3>
<p>The worst limitation is none of the ones I&rsquo;ve just recounted. It comes down to a phrase: <strong>the coupling is one-way</strong>. Weather drives the fire; the fire never modifies the atmosphere.</p>
<p>Yet a large wildfire makes its own weather. That is <strong>pyroconvection</strong>: the column of superheated air it sends skyward can rise several kilometers and form a <strong>pyrocumulonimbus</strong>, a genuine thunderstorm cloud born from the fire itself. It produces dry lightning that starts new fires tens of kilometers away, and it can collapse all at once, slamming a violent gust down to the ground, in any direction whatsoever.</p>
<p>That&rsquo;s exactly what lets fires we thought contained break loose, and it is <em>independent of the synoptic wind</em> &mdash; the large-scale wind, the one AROME forecasts. So it&rsquo;s invisible to my model. Real fire-atmosphere coupling exists, it goes by names like Meso-NH/ForeFire or WRF-SFIRE, and it requires laboratory-grade computing resources. Out of reach.</p>
<p>The compromise: not to simulate the phenomenon, but to assess its <em>potential</em>. There is an indicator for that, the <strong>continuous Haines index</strong>, which combines the instability of the air aloft with its dryness &mdash; in short, it says whether the sky “acts as a chimney” that day. Crossed with the fire&rsquo;s radiative power, it gives me a score. A fraction of my 200 draws, equal to that score, then switches into <strong>pyroconvective regime</strong>: far more uncertain direction, boosted spread, more frequent and more distant spotting. Those draws naturally feed the extreme envelope.</p>
<p>A high potential therefore reads as “the wide envelopes become credible,” never as “this is what will happen.”</p>
<p>Another blind spot, discovered while watching this week&rsquo;s heat wave roll in. My fine-fuel moisture comes from a classic fire-weather formula, Simard&rsquo;s, which looks only at <em>instantaneous</em> temperature and relative humidity. It has no memory. Yet three days at 38 °C dry out the litter in depth, and the soil along with it. Hence this absurd result: my model shows a fuel moisture that <em>rises</em> on the day it will hit 39.8 °C. So it underestimates the effect of a heat wave. What&rsquo;s needed are indices with memory, like the DMC and DC of the <strong>Canadian Forest Fire Weather Index</strong>, which accumulate the moisture deficit over days and weeks. That&rsquo;s the next project.</p>
<hr />
<h3 id="so-the-blayais">So, the Blayais?</h3>
<p><a href="https://pf.olibrio.fr/images/kokusho/carte-kokusho-grand.jpg" title="Open the screenshot at full size"><img alt="Screenshot of the online tool. On the left a map of the Aquitaine coast: the area already burned in gray, the active front in orange, and the 48-hour envelopes as yellow contours around two fires. On the right a panel giving, for each sensitive site, the threat level, the confidence, the distance to the front and the minimum simulated distances" src="https://pf.olibrio.fr/images/kokusho/carte-kokusho.jpg" /></a></p>
<p>Click to enlarge &mdash; or go see the live version at <a href="https://kokusho.ss2i.ca">kokusho.ss2i.ca</a>.</p>
<p>The answer stayed the same from the first run to the last: <strong>observed front at 36 kilometers, none of the 200 scenarios coming within 10 kilometers, wind pushing the opposite way</strong>. The plant was never threatened. And the tool says so with its reasons, not with a verdict.</p>
<p>The site actually exposed was elsewhere: the Cazaux air base, 7 kilometers from the Biscarrosse fire, with up to 6.7% of scenarios reaching its extended zone.</p>
<p>And then there was that last morning when Cazaux abruptly went orange, with 96.7% of scenarios reaching that same zone. Spectacular. And wrong.</p>
<p>The satellite had seen nothing for fifteen hours. My program had fallen back on its fallback assumption: for lack of knowing where the active front is, consider that the <em>entire</em> burned perimeter is active. So the fire was spreading in every direction at once, and my 96.7% no longer measured a convergence toward Cazaux &mdash; it measured my ignorance. The actually observed closing speed, for its part, was zero.</p>
<p>From now on, in that specific case, the level falls back to “high uncertainty, no approach observed” instead of crying fire &mdash; unless an approach is actually measured, in which case the alert stands. It may be the fix I&rsquo;m happiest with: it keeps the tool from turning an absence of information into an alert.</p>
<hr />
<h3 id="what-i-take-away-from-it">What I take away from it</h3>
<p>All of this was cobbled together in two days, and I didn&rsquo;t type the code: I had an AI write it. Same method as for the Czech convertible three weeks ago, but this time on a subject where being wrong is not trivial.</p>
<p>What cannot be delegated, on the other hand, is the rest: deciding that we would display envelopes and never a trajectory, choosing the sources, demanding that the model be tested against the fire&rsquo;s own past rather than against laboratory tests, and reopening the input map when a result smelled wrong. The machine writes fast and well. It doesn&rsquo;t know what needs checking.</p>
<p>Five fundamental errors were found in the model. What surprised me most was seeing <em>what caught them</em>:</p>
<ul>
<li>the fire imprisoned by its own scar: by an implausible number in a report;</li>
<li>the rasterization that bogged down: by a computation that never gave control back;</li>
<li>the flammable sea: <strong>because I finally looked at the input map</strong>, the one nobody thinks to open;</li>
<li>the coastline reconstruction that didn&rsquo;t hold up in real conditions: by a warning I had taken the trouble to write for that precise case;</li>
<li>the false orange alert: by confronting what the simulation said with what the observation said.</li>
</ul>
<p>None of them was found by the automated tests. I have sixty-four, and each of these bugs now has its own &mdash; they will prevent regression. But no test could have guessed that OpenStreetMap does not map the ocean. <strong>Tests check what you have already thought of. They are silent about the rest.</strong></p>
<p>What worked was friction with reality: the real data, the real map, the real past of the fire. Hindcasting taught me more in two runs than two days of development did.</p>
<p>And its main lesson was not “the model is off by this much.” It was: <strong>the model does not answer the question I thought I was asking it</strong>. I was asking it what this fire was going to do. It was answering what it would do if nobody stood in its way.</p>
<p>That is useful information &mdash; it&rsquo;s even exactly what you need to know what a wildfire is capable of. But it is not what you think you&rsquo;re reading on a map.</p>
<hr />
<p>🔥 <strong><a href="https://kokusho.ss2i.ca">See the tool online &mdash; kokusho.ss2i.ca</a></strong></p>
<p>Experimental, with no official standing. In the event of a wildfire, the only authoritative source is the préfecture: <a href="https://www.landes.gouv.fr/">landes.gouv.fr</a> · <a href="https://www.gironde.gouv.fr/">gironde.gouv.fr</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>The night works for us</title>
      <link>https://pf.olibrio.fr/en/posts/la-nuit-travaille-pour-nous.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/la-nuit-travaille-pour-nous.html</guid>
      <pubDate>Mon, 13 Jul 2026 12:00:00 +0000</pubDate>
      <description>37.7 °C outside, 30 °C in the bathroom, and the extractor fan hasn’t run all week. How an instrumented house learns to shed its heat at night.</description>
      <category>heatwave</category>
      <category>free cooling</category>
      <category>ventilation</category>
      <category>sensors</category>
      <category>insulation</category>
      <category>climate disruption</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/nuit/semaine-canicule.png" alt="The night works for us"></p>
<p>It&rsquo;s 4 p.m., it&rsquo;s 37.7 °C outside at the hottest point of the past twenty-four hours, and my bathroom reads 30 °C. Not the bathroom after a shower: the bathroom empty, shutters closed, nobody in it. That&rsquo;s what a heatwave does to a house &mdash; it fills it slowly, degree by degree, through the roof, through the walls, through every pane of glass, and by evening the house has become a radiator that hands the day back to you while you&rsquo;re trying to sleep.</p>
<p>Against that, there&rsquo;s not much you can do in the moment. But a heatwave has a weakness: <strong>the night</strong>. Around 4 a.m., the outside air drops to 20 °C, sometimes lower. For a few hours, there is out there a stock of free coolness, immense, renewed every night &mdash; and the only question worth asking is: how do we get it inside?</p>
<hr />
<h3 id="a-bathroom-extractor-fan-as-a-workhorse">A bathroom extractor fan as a workhorse</h3>
<p>The house already had everything it needed, actually. It just took looking at it differently.</p>
<p>Downstairs, a fresh-air inlet, originally installed to feed the wood stove. Upstairs, the bathroom &mdash; the hottest spot in the house, elementary physics. And between the two, a modest VMC (the French term for a mechanical ventilation unit) that, for years, has concerned itself only with humidity: it switches on when the shower fogs things up, off when it&rsquo;s dry.</p>
<p>But if you run that extractor fan at night, something more interesting happens: it sucks up the hot air accumulated upstairs, throws it outside, and the resulting low pressure draws the cool night air in through the inlet downstairs. The whole house becomes a circuit: fresh air comes in on the ground floor, crosses, rises, and leaves through the top, carrying the day&rsquo;s heat with it. The benefit isn&rsquo;t local to the bathroom &mdash; it&rsquo;s a <strong>house-scale stack effect</strong>, with a fan of a few tens of watts as the only engine.</p>
<p>The technical term for this is <em>free cooling</em>. I prefer the way we say it in French: the night works for us.</p>
<hr />
<h3 id="the-rule-and-the-traps-it-avoids">The rule, and the traps it avoids</h3>
<p>What remained was to write the decision rule. The little computer that drives the extractor fan now queries the weather and only switches on extraction if <strong>four conditions</strong> are met at the same time:</p>
<ol>
<li>the <strong>outdoor maximum over the past 24 hours</strong> is above 22 °C &mdash; we really are in the hot season;</li>
<li>the bathroom is above 24 °C &mdash; it really is hot upstairs;</li>
<li>the living room is above 20 °C &mdash; the house isn&rsquo;t already cooled down;</li>
<li>the outside air is <strong>at least 3 °C cooler</strong> than the inside &mdash; otherwise we&rsquo;d be stirring air for nothing.</li>
</ol>
<p>The subtlety is in the first condition. You might think it&rsquo;s enough to compare inside and outside. Wrong &mdash; and a mistake that would cost dearly in January: a house heated by the stove to 24 °C with 5 °C outside shows a 19 °C gap, more than enough to trigger a naive rule&hellip; which would then throw the wood&rsquo;s heat out the window, sucking in freezing air through the air inlet. The very same one that feeds the stove. The system would sabotage itself.</p>
<p>Hence the <em>season</em> criterion: the <strong>outdoor max over a rolling 24 hours</strong>. My January readings top out at 14.6 °C; the summer ones never drop below 22 °C. Between the two, a chasm &mdash; the guardrail can&rsquo;t be crossed by accident. And above all, it doesn&rsquo;t look at the temperature of the moment: a 13 °C night after a 32 °C day is precisely the time to extract, and an instantaneous threshold would have forbidden it.</p>
<hr />
<h3 id="what-the-numbers-say">What the numbers say</h3>
<p>Before wiring anything up, I replayed the rule against the real data from the past week &mdash; the house has been logging everything for years, it might as well be useful.</p>
<p><img alt="A week of heatwave: the outdoor temperature swings between 20 and 38 °C while the bathroom settles around 28-30 °C; at the far right, the fan command switches to ON" src="https://pf.olibrio.fr/images/nuit/semaine-canicule.png" /></p>
<p><em>The week in one picture: the outdoors (blue) breathes from 20 to 38 °C, the bathroom (pink) takes the hit and settles around 28-30 °C. And at the far right, that line going up: the fan command switching to ON &mdash; the system has just been armed, a few hours before its first night.</em></p>
<p>Verdict: over the week of July 6 to 13, the rule would have triggered <strong>48 hours of extraction</strong>. Average gap between inside and outside at the moments of triggering: <strong>4.9 °C</strong>. Time slots: concentrated between <strong>1 a.m. and 9 a.m.</strong>, never in the afternoon &mdash; the rule found the rhythm of the nights all by itself.</p>
<p>And the figure that amused me most: during that same heatwave week, the extractor fan didn&rsquo;t run <strong>a single minute</strong>. The air was so dry (29 to 37% humidity in the bathroom, against a trigger threshold of 60%) that its historical mission never woke it up. A perfectly functional motor, installed, wired, powered &mdash; and 100% dormant, precisely during the week when it had the most to offer. That&rsquo;s exactly the kind of untapped resource I love: zero hardware to buy, just a rule to write.</p>
<p>Tonight is the first night under real conditions. The unknown is a frank one: the air inlet was sized for a stove&rsquo;s combustion, not for ventilating a house. If its cross-section is too tight, the fan will mostly pull on the leaks in the building envelope and the actual airflow will be disappointing. Tomorrow morning&rsquo;s curve will settle it &mdash; if the bathroom drops off sharply compared with the previous nights, we&rsquo;ve won.</p>
<hr />
<h3 id="remarkable-events-7-c-and-417-c-the-same-year">Remarkable events: −7 °C and +41.7 °C, the same year</h3>
<p>A house that logs everything builds up its own little mythology. The table of outdoor records for 2026 almost speaks for itself:</p>
<ul>
<li><strong>January 6, 2026: −7.0 °C.</strong> The coldest day ever recorded by the station. The stove ran nonstop, the air inlet downstairs was sucking in frost.</li>
<li><strong>June 23, 2026: 41.7 °C.</strong> The hottest &mdash; before summer had even officially begun. Three hours in a row above 41 °C.</li>
</ul>
<p><img alt="Snow-covered plain in low-angled sunlight, clear sky, during the January 2026 cold spell" src="https://pf.olibrio.fr/images/nuit/janvier-neige.jpg" /></p>
<p><em>The morning of the record: the plain under snow, crystal-clear sky &mdash; the same clear sky that, six months later, would let the sun push the thermometer to 41.7 °C.</em></p>
<p><strong>A 48.7-degree range in under six months</strong>, at the same spot, measured by the same chain of sensors. The average, meanwhile, drifts without a sound &mdash; and that&rsquo;s precisely the trap.</p>
<p>It reminded me of Mélenchon, during his first presidential campaign, already bristling at the word “warming” (<em>réchauffement</em>): too gentle a word, almost the promise of an early spring, two more degrees on an average, who would even notice? And who insisted, pointedly, on “<strong>climate disruption</strong>” instead (<em>dérèglement climatique</em>, a climate knocked out of adjustment). The nuance isn&rsquo;t cosmetic, it&rsquo;s the whole question: what damages houses, crops and people isn&rsquo;t the average drifting &mdash; it&rsquo;s <strong>the extremes pulling apart in both directions</strong>. Sharper frosts <em>and</em> earlier heatwaves. A climate machine that hasn&rsquo;t simply gained a degree: one that has lost its thermostat.</p>
<p><img alt="Climate trend for July 13 in Puilboreau since 1970: minimums, averages and maximums over ±7 days, with a slope of +0.32 °C per decade" src="https://pf.olibrio.fr/images/nuit/tendance-13-juillet.png" /></p>
<p><em>July 13 over the years, since 1970 (Open-Meteo aggregates for my location). The average climbs by </em><em>+0.32 °C per decade</em><em> &mdash; 19.5 °C in the first years of the series, 21.5 °C over the last ten. Two degrees in half a century: that&rsquo;s the “warming,” and on this graph it looks harmless. What you only see by squinting is the pink curve of the maximums running wild &mdash; the recent peaks blow through the ceiling of the 1970s. That curve is the disruption.</em></p>
<p>My two records of 2026 make the case on their own. And that&rsquo;s exactly why you instrument a house: you don&rsquo;t prepare for an average, you prepare for extremes &mdash; in both directions.</p>
<hr />
<h3 id="under-the-hood-for-those-whod-like-to-build-it">Under the hood, for those who&rsquo;d like to build it</h3>
<p>Everything above runs on hardware from the back of a drawer &mdash; that&rsquo;s the important point, and the reason this post exists: anyone can put this together on a Sunday.</p>
<ul>
<li><strong>In the bathroom: a Raspberry Pi Zero W</strong> (~€15), a €2 <strong>DHT11</strong> temperature/humidity sensor on one GPIO, and a €2 <strong>433 MHz radio transmitter</strong> on another.</li>
<li><strong>The ventilation unit isn&rsquo;t modified by a single wire.</strong> It&rsquo;s plugged into a supermarket <strong>433 MHz remote-controlled outlet</strong> (~€10): the Pi simply replays the ON/OFF codes of the original remote (<code>rpi-rf</code> library, protocol 1, 350 µs pulse &mdash; the values are captured by listening to the remote with a €1 receiver). No 230 V wiring, reversible in thirty seconds.</li>
<li><strong>A small server somewhere</strong> &mdash; here a VPS for a few euros a month, but a NAS or a second Pi would do exactly the same: one PHP page, one <strong>SQLite</strong> database, and that&rsquo;s it.</li>
</ul>
<p><img alt="Raspberry Pi Model B Revision 2, the 2012 board" src="https://pf.olibrio.fr/images/nuit/pi-model-b.jpg" /> <img alt="Raspberry Pi Zero, the tiny board that drives the extractor fan" src="https://pf.olibrio.fr/images/nuit/pi-zero.jpg" /> <img alt="DHT11 temperature and humidity sensor, the little ridged blue box" src="https://pf.olibrio.fr/images/nuit/dht11.jpg" /></p>
<p><em>The house&rsquo;s workforce. Top, the veteran: a 2012 Raspberry Pi Model B &mdash; 14 years of service, it drives the living-room heating in Ruby. Middle, the bathroom&rsquo;s Pi Zero, which has just learned free cooling. Bottom, their eyes: the DHT11, the €2 temperature/humidity sensor &mdash; the little ridged blue box every tinkerer has come across at some point. (Photos: Gareth Halfacree CC BY-SA 2.0; Evan-Amos, public domain; Crackopl CC BY-SA 3.0.)</em></p>
<p><strong>Hunting down watts</strong> is part of the game, for machines that run around the clock. The Pi Zero draws ~0.6 W. The old Model B pulls ~2.5 &mdash; blame its 2012 linear regulators, which burn the excess voltage off as pure heat, and its USB/Ethernet chip, powered permanently. Nothing can be done about the architecture, but you can trim the software fat: rummaging through the veteran this week, I found a MySQL and an Apache from 2017 that had been running for nothing for nine years, an HDMI output active in NTSC with no screen on the end of it, a Bluetooth never paired, and a daemon waiting for shortcuts from a phantom keyboard. All of it cut: two degrees less on the processor in the middle of a heatwave, 38 MB of RAM given back, and above all no more pointless writes to the SD card &mdash; that&rsquo;s the real killer. On the scale of the bill it&rsquo;s anecdotal (2.5 W ≈ 22 kWh/year ≈ €5), but a system you want to see last ten years owes it to itself to be lean.</p>
<p>The architecture choices are all dictated by the same obsession: surviving outages, router reboots and the years.</p>
<ul>
<li><strong>No daemon, a cron.</strong> The controller launches every 5 minutes, lives for twenty seconds, measures, decides, sends its radio code, exits. A process that isn&rsquo;t running can neither leak nor hang &mdash; the Pi is at 267 days of uptime without giving it a thought.</li>
<li><strong>It&rsquo;s always the Pi that pulls, never the server that pushes.</strong> The Pi re-downloads its configuration (a YAML file) every minute with a plain <code>curl</code>, and queries the weather at decision time. Behind any router, NAT or IP change, it works &mdash; and the “Enable” box ticked on the web page takes effect in the field the following minute.</li>
<li><strong>The weather comes from Open-Meteo</strong> (open-meteo.com): a free API, no key, with history. An hourly cron archives it server-side, and the Pi receives a minimal JSON: current temperature, max over a rolling 24 h, living-room temperature. Serving only observations &mdash; never the forecast &mdash; matters: the 11 p.m. line was announcing 24 °C while it was 35 outside, and a rule fed on forecasts would have sucked in the furnace&rsquo;s air.</li>
<li><strong>A single table for everything</strong>: <code>observations (source, metric, value, unit, metadata)</code>. Sensors, setpoints, commands, weather &mdash; everything goes into the same mold. Adding a sensor means adding a <em>source</em>, zero migration. It&rsquo;s this table that made it possible to replay the rule against the archives before wiring it up.</li>
<li><strong>Fail closed.</strong> Server unreachable or weather more than two hours old → the Pi falls back to its original humidity logic. No 24 h max → no extraction (the season guardrail can&rsquo;t be bypassed by accident). The worst-case scenario of a failure is the behavior from before.</li>
<li>The curves on the page are drawn by <strong>uPlot</strong>, a ~50 KB library that handles tens of thousands of points without flinching.</li>
</ul>
<p>And a word on what, to my mind, is non-negotiable: <strong>this data never leaves my perimeter</strong>. What these sensors measure is the inside of a house &mdash; when someone showers, when it&rsquo;s heated, when it&rsquo;s empty. The Pis push their readings over SSH to a server <em>that I administer</em>, the page that displays them asks for a password, and the loop stops there. No manufacturer cloud, no Alexa “skill,” no home-automation SaaS that shuts down in three years taking the history with it, not one byte in anybody&rsquo;s analytics. The only outside dependency runs the other way: the house <em>downloads</em> Open-Meteo&rsquo;s public weather, it sends nothing. The day the server changes &mdash; a NAS, another VPS, a shoebox &mdash; you move one SQLite file and everything starts back up.</p>
<p>Total budget per instrumented room: <strong>under €30</strong>. The luxury isn&rsquo;t in the hardware &mdash; it&rsquo;s in the years of data you accumulate, that you can query the day you have an idea, and that belong to no one but you.</p>
<hr />
<h3 id="next-getting-into-the-thickness-of-things">Next: getting into the thickness of things</h3>
<p>So far, all my sensors measure <em>rooms</em>. The next step is more intimate: getting <strong>inside the insulation</strong>.</p>
<p>The project: a rod planted vertically in the attic insulation, with four temperature probes at staggered depths &mdash; surface, one third, two thirds, bottom against the plasterboard. Above it, a fifth probe in the air under the roof tiles, which climbs to 60-70 °C right now. Below, the living-room thermostat, already in place. A complete measurement column, from the sun to the living space.</p>
<p>What it will tell, day after day:</p>
<ul>
<li><strong>The real thermal lag</strong>: how many hours does the heat peak under the tiles take to cross the insulation? The manufacturer states a theoretical value; the settled, aged insulation does as it pleases. I want the true number, in my house.</li>
<li><strong>The damping</strong>: by how much is the day/night amplitude squashed between the surface and the bottom?</li>
<li><strong>The effect of the draft chimneys</strong> I installed on the roof to evacuate the heat under the tiles: in comparable weather, the insulation surface should be cooler if the draft is doing its job. Two columns of probes &mdash; one near the chimneys, one far away &mdash; and the answer will fall out on its own.</li>
<li>And in the long run, an <strong>aging detector</strong>: a thermal lag that shortens year after year is insulation that&rsquo;s degrading. Better to know before the heating bill says so.</li>
</ul>
<p>Hardware-wise, nothing heroic: waterproof probes at a few euros each on a one-pair-of-wires bus, a microcontroller that sleeps 99.9% of the time on two salvaged rechargeable cells &mdash; enough to last about two years without changing the batteries. The electronics stay cool at the bottom of the attic; only the probes go up to the front line.</p>
<hr />
<h3 id="and-in-winter-we-flip-everything-around">And in winter, we flip everything around</h3>
<p>The heatwave will end. And the same intellectual plumbing will serve again, turned inside out like a glove.</p>
<p>The next project running through my head: <strong>air-based solar thermal panels on the outside wall</strong> &mdash; dead-simple solar collectors, a glazed black box in which air warms up in the winter sun. Even at 5 °C outside, a well-exposed panel puts out air at 30 or 40 °C in the middle of the day. Hooked up to the house&rsquo;s air inlet, it becomes <strong>free preheating</strong>: instead of drawing in 5 °C air to feed the stove and make up for the extraction, we draw in air already warmed by the sun.</p>
<p>And the loop closes elegantly: it&rsquo;s the same decision logic as free cooling, inverted. In summer, we extract at night when <em>outside \&lt; inside</em>. In winter, we&rsquo;ll inject during the day when <em>panel &gt; inside</em>. The same sensors, the same weather, the same four-condition rule with its guardrails &mdash; only the sign changes. The probes in the insulation will even say whether the preheating shows up in the attic gradient.</p>
<p>A house, at bottom, is a thermal system that doesn&rsquo;t know it. Mine is only just getting to know itself &mdash; and tonight, for the first time, it&rsquo;s going to try to cool itself down while I sleep. I&rsquo;ll tell you tomorrow whether it managed.</p>
<hr />
<h3 id="postscript-on-the-morning-of-july-14">Postscript, on the morning of July 14</h3>
<p>The first night has happened. The fan extracted for nearly <strong>twelve hours</strong>, from 7 p.m. to 8 a.m., with a one-hour pause around 1 a.m. &mdash; the gap had fallen back below the stop threshold, the rule did exactly what it was taught.</p>
<p>Raw result: bathroom from <strong>31 °C at bedtime to 26 °C at rising</strong>. The best night of the week. But the figure I like is elsewhere. On the previous nights, the outdoors dropped to 23-24 °C and the bathroom stayed stuck at 28: a four-to-five-degree gap in the morning, the signature of a house that exchanges nothing with the night. This time, the controller held the gap between <strong>1.4 and 2.4 °C until morning</strong> &mdash; the inside <em>followed</em> the outside on its way down, hour after hour. The air renewal is really there, and the air-inlet unknown is resolved: it delivers.</p>
<p>Transparency demands it: the windows were open that night, heatwave obliging &mdash; impossible to separate their share from the fan&rsquo;s. The next night will be done <strong>windows closed, extractor fan alone</strong>. If the bathroom still hugs the outdoors to within two degrees in the morning, the demonstration will be complete. Domestic science advances one night at a time.</p>
<hr />
<p><em>Next episode: the curves from the first night with the windows closed, and what 48 hours of nighttime draft change (or don&rsquo;t) about a heatwave.</em></p>]]></content:encoded>
    </item>
    <item>
      <title>Reverse-engineering a 1970 sofa bed, with an AI at Blender&#x27;s controls</title>
      <link>https://pf.olibrio.fr/en/posts/retro-ingenierie-convertible-1970.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/retro-ingenierie-convertible-1970.html</guid>
      <pubDate>Sun, 05 Jul 2026 12:00:00 +0000</pubDate>
      <description>A Czechoslovak sofa bed spotted online, an AI driving Blender, and three wrong theories before the right one. A parametric reverse-design, missteps included.</description>
      <category>blender</category>
      <category>ai</category>
      <category>furniture</category>
      <category>mid-century</category>
      <category>parametric</category>
      <category>mechanics</category>
      <content:encoded><![CDATA[<p><img src="https://pf.olibrio.fr/images/sofa/00-illustration-manga.png" alt="Reverse-engineering a 1970 sofa bed, with an AI at Blender&#x27;s controls"></p>
<p><img alt="The OPP Drevovyroba sofa bed, in ink-and-screentone version" src="https://pf.olibrio.fr/images/sofa/00-illustration-manga.png" /></p>
<p><em>Illustration: a manga-style reinterpretation of one of the seller&rsquo;s photos &mdash; the original is on <a href="https://justchairs.eu/products/mid-century-convertible-sofa-by-opp-drevovyroba-1970s">justchairs.eu</a>.</em></p>
<p>I came across a 1970s Czechoslovak convertible sofa online, made by OPP Drevovyroba, <a href="https://justchairs.eu/products/mid-century-convertible-sofa-by-opp-drevovyroba-1970s">for sale at a dealer in Budapest</a>. Low profile, sculptural armrests in bent teak plywood, and a mechanism described in a single sentence: “the sofa turns into a bed in one motion.” €1,550, 1,500 km from home, and dimensions that don&rsquo;t fit my living room. The conclusion was obvious: rather than buy it, redraw it &mdash; and understand it along the way.</p>
<p>This post is the story of that exercise, carried out with an AI (Claude) driving Blender live. Spoiler: the AI got the mechanism wrong three times, and that is precisely what makes the story interesting.</p>
<h2 id="the-setup">The setup</h2>
<p>On the tooling side, this has become almost mundane: Blender, a small add-on that opens a server on the machine (<a href="https://github.com/ahujasid/blender-mcp">BlenderMCP</a>), and the AI that connects to it. Ten minutes to install. From there, it writes Python inside Blender, builds geometry, takes screenshots of the viewport to check its work, and starts over. Me, I watch the model turn live on my screen and comment.</p>
<p>The choice made from the outset: a <strong>parametric</strong> model. Not mouse-driven sculpting, but a script with a block of parameters at the top &mdash; width, seat height, cushion thickness, backrest angle &mdash; and all the geometry, mechanism included, recomputed whenever a value changes. As we&rsquo;ll see, that choice saved the day more than once.</p>
<p><img alt="The model in sofa position" src="https://pf.olibrio.fr/images/sofa/04-canape-34.png" /></p>
<h2 id="three-theories-for-one-mechanism">Three theories for one mechanism</h2>
<p>The seller provides about fifteen photos, none of the mechanism in action. So it had to be deduced.</p>
<p><strong>Theory no. 1: the simple hinge.</strong> The AI&rsquo;s first interpretation: the backrest tips backward on a hinge, like a click-clack sofa. Modeled, animated&hellip; and demolished by looking more closely at the photos: in bed position, the front strip of the sleeping surface is <em>in front of</em> the seat, not behind it. So the backrest passes over the top of the seat. Exit the hinge.</p>
<p><strong>Theory no. 2: four-bar kinematics.</strong> If the backrest has to fly over the seat without hitting it, I told myself, it has to rotate <em>and</em> translate &mdash; hence two pivots and some connecting links. The AI took the idea seriously, very seriously in fact: it wrote a solver that places the pivots by numerical search along the perpendicular bisectors of the two poses, maximizing the clearance between backrest and seat throughout the swing. Result: a perfectly functional four-bar mechanism, 17 mm clearance verified over the whole travel. Elegant. And completely wrong.</p>
<p><strong>Theory no. 3: the single pivot.</strong> A close-up of the armrest settled it: the screws attaching the arm to the backrest are a <em>rigid</em> fastening, not a pivot. Arm and backrest form a single moving part, which rotates around the round trunnion visible on the side of the box. One pivot per side. And then everything falls into place: since the pivot is low and central, the arc of rotation <em>rises</em> above the seat. There never was a collision problem to solve. The Czech designers didn&rsquo;t need linkages &mdash; they had simply placed an axle well.</p>
<p>The lesson deserves a frame: <strong>when the AI misunderstands the real world, it doesn&rsquo;t err timidly &mdash; it over-engineers.</strong> The four-bar mechanism was the brilliant answer to a problem that didn&rsquo;t exist.</p>
<p><img alt="Mid-swing: the backrest flies over the seat" src="https://pf.olibrio.fr/images/sofa/02-mi-bascule.png" /></p>
<p>The beautiful mathematical gesture survived the shipwreck of theory no. 2: two poses of a rigid body in the plane determine a unique center of rotation &mdash; the intersection of the perpendicular bisectors. No more numerical search: the pivot is <em>computed</em>, exactly. And when given the right input geometry, it lands within a few millimeters of the original trunnion&rsquo;s location. A nice cross-validation, fifty years later.</p>
<h2 id="the-armrest-that-becomes-a-leg">The armrest that becomes a leg</h2>
<p>Another find from the bed-position photos: the backrest cantilevers 46 cm out in front of the box, and it&rsquo;s <em>the armrest</em> that supports it &mdash; as it swings, the armrest&rsquo;s branch points toward the floor and its tip rests there, an improvised strut. The little platform where you rested your elbow becomes a foot pad.</p>
<p>In the parametric model, we inverted the logic: rather than draw the armrest and then observe where it lands, the AI computes its bearing points <strong>by inverse rotation from their target on the floor</strong>. The tip of the armrest is defined as “the point which, after a 110° rotation, touches the floor at y = -47 cm.” Check at the final frame: z = 0.0 mm. Which also explains <em>why</em> the original armrest is so short and set back: its position is not an aesthetic choice, it&rsquo;s the landing constraint. Form follows mechanism.</p>
<p><img alt="Bed position: cantilever supported by the armrest" src="https://pf.olibrio.fr/images/sofa/03-lit-profil.png" /></p>
<h2 id="where-we-go-beyond-the-original">Where we go beyond the original</h2>
<p>Understanding is good; improving is better. Three flaws of the original piece went through the wringer.</p>
<p><strong>The two-firmness sleeping surface.</strong> On the original, a thick seat and a thinner backrest make for a bed with two zones. Fix: a common thickness of 14.5 cm everywhere, a perfectly uniform 126 × 200 cm sleeping surface.</p>
<p><strong>The 65 cm deep seat.</strong> Impossible to lean back while keeping your feet on the floor. On closer inspection, the original already cheats: its backrest isn&rsquo;t at the rear edge but <em>pushed forward</em> on the platform, and the dead zone behind it becomes sleeping surface when unfolded. We parameterized that cleanly: <code>prof_utile = 50 cm</code>, and the pivot recomputes itself.</p>
<p><strong>The soft side facing the wrong way.</strong> The real head-scratcher. The forward swing exposes the <em>back</em> of the backrest as the sleeping surface: if the front face is soft for sitting, you sleep on the firm face. The original made its choice, and too bad for the seat. Our solution: a <strong>central axle</strong> running through the middle of the backrest. During the swing, the backrest makes a half-turn on itself &mdash; and the same soft face serves as backrest by day and sleeping surface by night. Cascading consequence: the arm now stops halfway up the backrest to carry that bearing&hellip; exactly at armrest height. The armrest <em>is</em> the bearing.</p>
<p>That left keeping the backrest from spinning freely. First idea: a slide bolt. Second idea, much better: a <strong>pin running in a 180° semicircular groove</strong>, concentric with the axle, milled into the inner face of the arm. The ends of the groove are the stops for the two positions &mdash; the travel of the groove <em>is</em> the travel of the half-turn. No latch to operate, automatic indexing, and in both poses the loads press the pin against its stop. Verified in the model: the pin sits exactly at the end of the groove in both positions.</p>
<p><img alt="The 180° groove and its pin, on the inner face of the arm" src="https://pf.olibrio.fr/images/sofa/06-gorge-teton.png" /></p>
<p><img alt="Bed position: the soft face is on top" src="https://pf.olibrio.fr/images/sofa/05-lit-34-face-moelleuse.png" /></p>
<h2 id="what-i-take-away-from-it">What I take away from it</h2>
<p><strong>A picture is worth a thousand tokens.</strong> The three decisive corrections came from close-ups I sent to the AI: the rigid screws, the armrest-turned-leg, the forward-set backrest. It reasons remarkably well &mdash; but about what it&rsquo;s shown.</p>
<p><strong>The AI over-engineers when it misunderstands.</strong> The four-bar solver was magnificent and useless. Beware of brilliant solutions: check the problem first.</p>
<p><strong>Parametric makes mistakes cheap.</strong> Three complete overhauls of the mechanism, and each time a few minutes of recomputation rather than hours of remodeling. Since the anchor points (pivot, floor contacts, bearing) are computed rather than placed by hand, everything follows.</p>
<p><strong>The collaboration makes sense.</strong> It computes intersections of perpendicular bisectors and checks clearances to the millimeter; I can see that a 65 cm deep seat is unlivable and that a screw is not a pivot. Neither of us would have produced this design alone.</p>
<p>To be continued in the next episode: dimensioned 2D drawings, details of the bearing and the groove, and fabrication in 18 mm plywood. The script and the model are already running &mdash; all that&rsquo;s missing are the dimensions of my living room.</p>
<h2 id="the-files">The files</h2>
<p>Everything is downloadable, help yourself:</p>
<ul>
<li><a href="https://pf.olibrio.fr/assets/sofa/sofa_parametrique.py"><code>sofa_parametrique.py</code></a> &mdash; the complete script: parameters, pivot computation, construction, animation. Open it in Blender (Scripting tab) and rerun it after changing the <code>PARAMS</code>.</li>
<li><a href="https://pf.olibrio.fr/assets/sofa/canape_convertible.blend"><code>canape_convertible.blend</code></a> &mdash; the Blender scene with the swing animation (frame 1 = sofa, frame 61 = bed).</li>
<li><a href="https://pf.olibrio.fr/assets/sofa/canape_convertible.glb"><code>canape_convertible.glb</code></a> &mdash; the model in glTF format, animation included, readable everywhere (online 3D viewers, game engines, AR).</li>
</ul>
<hr />
<p><em>Photos of the original sofa: <a href="https://justchairs.eu/products/mid-century-convertible-sofa-by-opp-drevovyroba-1970s">justchairs.eu</a>. 3D model: Blender 5.1 + <a href="https://github.com/ahujasid/blender-mcp">BlenderMCP</a>.</em></p>]]></content:encoded>
    </item>
    <item>
      <title>My son, brainrot and me: a father&#x27;s investigation inside the dopamine machine</title>
      <link>https://pf.olibrio.fr/en/posts/brainrot-machine-a-dopamine.html</link>
      <guid isPermaLink="true">https://pf.olibrio.fr/en/posts/brainrot-machine-a-dopamine.html</guid>
      <pubDate>Tue, 09 Jun 2026 12:00:00 +0000</pubDate>
      <description>My son was getting irritable after his Roblox videos. Instead of speculating, I analyzed the transcripts of his 10 favorite videos. Here is what the data said.</description>
      <category>parenting</category>
      <category>screens</category>
      <category>dopamine</category>
      <category>roblox</category>
      <category>brainrot</category>
      <category>attention</category>
      <content:encoded><![CDATA[<h3 id="act-i-the-symptom">Act I &mdash; The symptom</h3>
<p>It all started with a stupid argument.</p>
<p>&ldquo;Turn it off, time&rsquo;s up.&rdquo; An ordinary sentence. Except that evening, the reply wasn&rsquo;t a normal teenage sigh. It was curt, raw, almost hostile &mdash; as if I had just ripped something away from him. Ten minutes later, he was himself again. But in between, there had been that <strong>moment</strong>: a kid I didn&rsquo;t recognize, irritable, shut down, somewhere else.</p>
<p>And it wasn&rsquo;t the first time. The pattern kept coming back: he would be watching Roblox videos &mdash; a YouTuber named <strong>Kevko</strong>, some <em>Steal a Brainrot</em>, some <em>Lucky Block</em> &mdash; and when I interrupted him right in the middle, things went sideways. Not afterward. <strong>Right in the middle.</strong></p>
<p>I could have left it there. Declared &ldquo;screens are bad,&rdquo; set a rule, and worn myself out enforcing it. But I had a more precise hunch, and a son too smart to swallow a ban without an explanation. I needed to understand <em>exactly</em> what was going on in his head. And for that, I needed <strong>data</strong>, not impressions.</p>
<hr />
<h3 id="act-ii-the-investigation">Act II &mdash; The investigation</h3>
<p>First hypothesis, the most worrying one: what if the content itself was the problem? A lot of Roblox videos revolve around <em>stealing, beating, trapping, trolling, winning against everyone else</em>. Was my son spending his afternoons internalizing a logic of competition and domination?</p>
<p>The trouble was, I had no idea. I <em>felt</em>. And when it comes to raising kids, gut feeling is the shortest road to unfairness.</p>
<p>So I decided to do what a journalist would do: <strong>read the sources</strong>. Not half-watch two videos &mdash; pull the <strong>full transcripts</strong> of the channel&rsquo;s biggest videos and analyze them, word by word.</p>
<p>I identified his <strong>10 most-viewed videos</strong> (from 426,000 to 786,000 views), extracted the automatic French subtitles, and cleaned everything up: <strong>48,437 words</strong> of transcript. Then I counted. I sorted the vocabulary into broad families &mdash; urgency, competition, reward, accumulation, conflict, mockery &mdash; and measured their frequency per 1,000 words.</p>
<p>That&rsquo;s when the numbers started talking. And they didn&rsquo;t say what I expected.</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Theme (across 10 videos, 48,000 words)</th>
<th>Frequency /1,000 words</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Scarcity / accumulation</strong> (<code>encore</code>, <code>obtenir</code>, <code>débloquer</code>, <code>rare</code>)</td>
<td><strong>11.4</strong></td>
</tr>
<tr>
<td><strong>Urgency / speed</strong> (<code>vite</code>, <code>vitesse</code>, <code>allez</code>, <code>go</code>)</td>
<td><strong>10.1</strong></td>
</tr>
<tr>
<td>Reward / money (<code>robux</code>, <code>million</code>, <code>argent</code>)</td>
<td>6.3</td>
</tr>
<tr>
<td>Competition / winning</td>
<td>5.0</td>
</tr>
<tr>
<td>Excitement / superlatives</td>
<td>3.3</td>
</tr>
<tr>
<td>Theft (&ldquo;steal&rdquo;)</td>
<td>2.3</td>
</tr>
<tr>
<td><strong>Conflict / domination</strong></td>
<td><strong>0.4</strong></td>
</tr>
<tr>
<td><strong>Mockery / trolling</strong></td>
<td><strong>0.5</strong></td>
</tr>
</tbody>
</table></div>
<p>The aggressive content I had been dreading &mdash; domination, mockery, trolling &mdash; was <strong>practically nonexistent</strong>: 0.4 and 0.5 per 1,000 words, basically statistical noise. My son wasn&rsquo;t learning to crush other people.</p>
<p>What dominated was something else entirely: <strong>accumulation and urgency.</strong> <em>Always one more item. Always faster.</em> The two most frequent theme words on the entire channel were &ldquo;encore&rdquo; (more) and &ldquo;vite&rdquo; (fast). And when I measured interjections and laughter &mdash; a proxy for intensity &mdash; some videos climbed to <strong>23 excitement markers per 1,000 words</strong>, without a single pause for breath.</p>
<p>I had the wrong suspect. The danger wasn&rsquo;t <em>ideological</em>. It was <strong>physiological</strong>.</p>
<hr />
<h3 id="act-iii-what-we-found">Act III &mdash; What we found</h3>
<p>All that was left was to check my numbers against the science. And that&rsquo;s when everything clicked into place.</p>
<p><strong>1. The unpredictable reward machine.</strong> The heart of <em>Steal a Brainrot</em> is chance: the Lucky Block that <em>might</em> drop a rare brainrot, the passive income that keeps ticking and nudges you to log back in. That is precisely <strong>variable-ratio reinforcement</strong> &mdash; the mechanism casinos have exploited for a century because it&rsquo;s the most addictive one there is. The brain releases dopamine <strong>while anticipating</strong> the reward, not upon receiving it. You stay in permanent tension: <em>what if the next one is the one?</em></p>
<p><strong>2. It&rsquo;s not the violence, it&rsquo;s the pace.</strong> The key piece I found in a study published in the journal <em>Pediatrics</em> in 2011. Researchers showed children <strong>9 minutes</strong> of a cartoon that was simply <em>fast</em> &mdash; a cut every eleven seconds &mdash; with no violence at all. Right afterward, their ability to concentrate and their patience were <strong>measurably degraded</strong>, compared with children who had watched a slow cartoon. Nine minutes. The Kevko format is exactly that: saturated delivery, choppy editing, zero breathing room. <strong>It isn&rsquo;t what he says that acts on my son. It&rsquo;s the speed.</strong></p>
<p><strong>3. And that&rsquo;s why it was blowing up at home.</strong> When I cut him off in the middle, my son&rsquo;s brain was still &ldquo;up.&rdquo; And the American Academy of Pediatrics says it explicitly: when the screen goes off, <strong>dopamine drops, and that drop leaves us grumpy and irritable.</strong> The irritability I was taking full in the face wasn&rsquo;t insolence. It was a <strong>predictable chemical state</strong> &mdash; the backlash of an excited state that hadn&rsquo;t been allowed to come back down.</p>
<p>The cruelest part is the relational effect. The content put him in a state where he was <strong>pushing away, without meaning to, the people in the same room.</strong> And the trap of <em>&ldquo;always one more&rdquo;</em> is that there is never a good moment to stop &mdash; so never really a moment when he was <em>present</em> with us. The real cost wasn&rsquo;t screen time. It was real moments, with real people, disappearing.</p>
<p><strong>The nuance, because we have to stay honest.</strong> No, brainrot doesn&rsquo;t literally &ldquo;rot the brain&rdquo; &mdash; it became the Oxford dictionary&rsquo;s word of the year in 2024, but it&rsquo;s a cultural marker, not a diagnosis. No, the famous &ldquo;dopamine detox&rdquo; isn&rsquo;t a real scientific concept: you don&rsquo;t &ldquo;reset&rdquo; your dopamine by switching off screens. And yes, brainrot is also kids&rsquo; humor, a generational language. My son is not in danger. But he is, by design, <strong>being manipulated</strong> &mdash; and that is something we can fix.</p>
<hr />
<h3 id="act-iv-what-were-trying-to-do-about-it">Act IV &mdash; What we&rsquo;re trying to do about it</h3>
<p>I decided to make a bet: on his intelligence.</p>
<p>No head-on ban &mdash; a clever kid works around it and resents you for it. Instead, I switched sides. The message was no longer <em>&ldquo;Dad forbids it&rdquo;</em> but <em>&ldquo;engineers paid millions are playing with your brain, and I&rsquo;m on your team against them.&rdquo;</em> Nothing annoys a teenager more than being manipulated. That was my best lever.</p>
<p>Then I proposed an <strong>experiment</strong>, because he likes science:</p>
<blockquote>
<p>&ldquo;For one week, just observe your mood in the 10 minutes after you stop. Rate it out of 10. And compare: when someone cuts you off in the middle, versus when <em>you</em> pick the moment to stop. If I&rsquo;m wrong, I&rsquo;ll admit it. But look at the data yourself.&rdquo;</p>
</blockquote>
<p>Giving him back control is precisely what the research identifies as the factor that most reduces end-of-screen meltdowns. And turning a recurring argument into a <strong>joint investigation</strong> takes him out of the defensive position.</p>
<p>Finally, we watched some videos <strong>together</strong> &mdash; not sermons, but breakdowns made by people a teenager respects:</p>
<ul>
<li><strong>&ldquo;Dopamine&rdquo; series &mdash; ARTE, TikTok episode</strong> (~9 min): dissects, technique by technique, the app he knows. Clinical tone, zero moralizing, the &ldquo;they&rsquo;re manipulating me&rdquo; click. → https://www.youtube.com/watch?v=-pOWdBpVR3s</li>
<li><strong>&ldquo;Et tout le monde s&rsquo;en fout&rdquo; &mdash; #59 Les algorithmes</strong> (~7 min): funny, on your side, never preachy. → https://www.youtube.com/watch?v=5NiVg4DBJrI</li>
<li><strong>Tracks ARTE &mdash; &ldquo;Brainrot: est-ce qu&rsquo;internet fait pourrir nos cerveaux ?&rdquo;</strong> : tackles his universe head-on. <em>(Preview it first.)</em> → https://www.youtube.com/watch?v=ubT8hS0lAlg</li>
<li><strong>Stupid Economics &mdash; &ldquo;L&rsquo;économie de l&rsquo;attention&rdquo;</strong>: the &ldquo;your attention is the product being sold&rdquo; angle. → https://www.youtube.com/watch?v=rMV1WaWGb3I</li>
<li><strong>Tristan Harris &mdash; TED Talk (French subtitles)</strong>: the former Google engineer who sounded the alarm first. Maximum credibility. → https://www.ted.com/talks/tristan_harris_how_a_handful_of_tech_companies_control_billions_of_minds_every_day?language=fr</li>
</ul>
<p>The plan: start with the first one (9 minutes, he recognizes his app), let him comment, don&rsquo;t draw the conclusion for him. The short, factual format does the work on its own.</p>
<hr />
<h3 id="epilogue">Epilogue</h3>
<p>I haven&rsquo;t &ldquo;won.&rdquo; This isn&rsquo;t a battle you win in one evening, and that was never the point. But something has changed: my son stopped seeing a father who confiscates, and started seeing a mechanism that manipulates him. He has a vocabulary to name it now. And between us, conversation has replaced argument.</p>
<p>The content he watches isn&rsquo;t evil. It isn&rsquo;t even ideological &mdash; my own data proved it to me. It&rsquo;s just a machine, very well designed, to keep a child&rsquo;s brain in a state of maximum excitement and make it come back. Understanding the machine is already halfway to breaking free of it.</p>
<p>And that is a bet I&rsquo;m willing to make on my son&rsquo;s intelligence.</p>
<hr />
<h2 id="sources-references">Sources &amp; references</h2>
<p><strong>On dopamine and post-screen irritability</strong> - American Academy of Pediatrics &mdash; <em>Screen Time and Temper Tantrums</em> (the dopamine drop when the screen goes off makes kids irritable): https://www.healthychildren.org/English/family-life/Media/Pages/screen-time-and-temper-tantrums-helpful-tips-for-parents.aspx</p>
<p><strong>On pacing/editing that degrades attention</strong> - Lillard, A. S., &amp; Peterson, J. (2011). <em>The Immediate Impact of Different Types of Television on Young Children&rsquo;s Executive Function.</em> <strong>Pediatrics</strong>, 128(4), 644-649. (The &ldquo;SpongeBob&rdquo; study.) Accessible summary (NPR): https://www.npr.org/sections/health-shots/2011/09/12/140401099/spongebob-may-be-too-speedy-for-preschool-brains - Recent meta-analysis on the short-term effects of media on children&rsquo;s attention and executive functions (PMC, 2024): https://pmc.ncbi.nlm.nih.gov/articles/PMC12412071/</p>
<p><strong>On reward loops and addictive design</strong> - <em>The Vegas Effect of Our Screens</em> &mdash; Psychology Today (variable-ratio reinforcement): https://www.psychologytoday.com/us/blog/tech-happy-life/201901/the-vegas-effect-of-our-screens - <em>Why We Can&rsquo;t Stop</em> &mdash; Communication Research / Taylor &amp; Francis (rewarding elements of games and problematic gaming among teens): https://www.tandfonline.com/doi/full/10.1080/15213269.2023.2242260 - <em>Debunking the Dopamine Detox Trend</em> &mdash; The Scientist (the &ldquo;dopamine detox&rdquo; is a myth): https://www.the-scientist.com/debunking-the-dopamine-detox-trend-72036</p>
<p><strong>On &ldquo;brainrot&rdquo; and <em>Steal a Brainrot</em></strong> - <em>&ldquo;Brain rot&rdquo; named Oxford Word of the Year 2024</em> &mdash; Oxford University Press: https://corp.oup.com/news/brain-rot-named-oxford-word-of-the-year-2024/ - <em>Steal a Brainrot</em> &mdash; encyclopedia entry (passive income, gameplay loop, monetization): https://en.wikipedia.org/wiki/Steal_a_Brainrot</p>
<p><strong>On using screens as an emotional regulator</strong> - <em>Using screens to calm kids may hurt their emotional regulation</em> &mdash; CNN (reporting on a study published in JAMA Pediatrics): https://edition.cnn.com/2022/12/12/health/tantrum-distraction-screens-parenting-wellness</p>
<p><strong>Explainer video resources (cited in the article)</strong> - <em>Dopamine</em> series &mdash; ARTE (playlist): https://www.youtube.com/playlist?list=PL8Ax_z5vzflwdvoTnARE5FiYl42Sg-fYb - <em>Et tout le monde s&rsquo;en fout</em> &mdash; #59 Les algorithmes: https://www.youtube.com/watch?v=5NiVg4DBJrI - <em>Brainrot : est-ce qu&rsquo;internet fait pourrir nos cerveaux ?</em> &mdash; Tracks / ARTE: https://www.youtube.com/watch?v=ubT8hS0lAlg - <em>L&rsquo;économie de l&rsquo;attention</em> &mdash; Stupid Economics: https://www.youtube.com/watch?v=rMV1WaWGb3I - Tristan Harris &mdash; TED (French subtitles): https://www.ted.com/talks/tristan_harris_how_a_handful_of_tech_companies_control_billions_of_minds_every_day?language=fr</p>
<hr />
<p><em>Methodological note: the lexical analysis covers the automatic transcripts (French YouTube subtitles) of the channel&rsquo;s 10 most-viewed videos, about 48,000 words. Acknowledged limitation: a transcript measures words, not sound volume or editing pace &mdash; and that pace is precisely what the science identifies as the most decisive factor. The figures are therefore a converging clue, not a direct measurement of overstimulation.</em></p>]]></content:encoded>
    </item>
  </channel>
</rss>
