<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Israel Negrete Lepe</title>
    <description>The latest articles on DEV Community by Israel Negrete Lepe (@crtdrops).</description>
    <link>https://dev.to/crtdrops</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4162441%2Ff4c723f5-463d-408e-ac0a-953461375528.jpg</url>
      <title>DEV Community: Israel Negrete Lepe</title>
      <link>https://dev.to/crtdrops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/crtdrops"/>
    <language>en</language>
    <item>
      <title>The Electratón runs again: a 2001 energy controller, back on the track</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-electraton-runs-again-a-2001-energy-controller-back-on-the-track-27ka</link>
      <guid>https://dev.to/crtdrops/the-electraton-runs-again-a-2001-energy-controller-back-on-the-track-27ka</guid>
      <description>&lt;p&gt;In November 2001, for my social service—the community-service requirement for graduating in Mexico—I worked with two classmates on the energy control of an electric competition vehicle, an &lt;em&gt;Electratón&lt;/em&gt;. The problem was concrete: current peaks above 60 A were significantly hurting the batteries' range.&lt;/p&gt;

&lt;p&gt;Twenty-five years later I wanted to go back to that problem. Not to show that today you can write a more complex controller, but to see how much of that answer still stands when you put it against a simulator, and how much depends on things we could not measure in 2001.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem of November 2001
&lt;/h2&gt;

&lt;p&gt;From the document of that time I still have this: a controller to manage the vehicle's energy demand, above all when accelerating and after braking, on an ATMEL 8535 programmed in assembly. The documented diagnosis tied the peaks above 60 A to a significant loss of range.&lt;/p&gt;

&lt;p&gt;The design had three mechanisms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Soft start:&lt;/strong&gt; avoid a sudden current demand when starting to accelerate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovering the operating point:&lt;/strong&gt; after braking, keep the motor from jumping straight back into a high-demand condition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 60 A comparator:&lt;/strong&gt; detect when the current went above that threshold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system also logged voltage, current and speed every 20 seconds, and the data was downloaded over a serial link.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints of an AVR in assembly
&lt;/h2&gt;

&lt;p&gt;The 8535 was an 8-bit microcontroller with 8 KB of program memory and 512 bytes of RAM. Everything was written in assembly: every comparison, every count. There was no operating system and no libraries.&lt;/p&gt;

&lt;p&gt;You do not build a model of the vehicle or run an optimization with that. You make simple decisions that fit in a few cycles: ramp up slowly, do not go past a threshold, do not jump back. What is interesting is that those simple decisions were, underneath, an energy policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we kept and what we can no longer verify
&lt;/h2&gt;

&lt;p&gt;I have to be precise here, because it is easy to reconstruct too much.&lt;/p&gt;

&lt;p&gt;I do not have the 2001 source code. I did not recover the motor specifications, the battery chemistry or capacity, or any lap or range results. I am also not going to state a format of that competition's rules without the documentation.&lt;/p&gt;

&lt;p&gt;I do not know everything about the controller either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The shape of the soft start: a progressive increase of the drive is a reasonable reading, but I have neither its shape nor its duration on record.&lt;/li&gt;
&lt;li&gt;What exactly the “operating point” after braking was: a duty cycle, a speed reference or some other parameter.&lt;/li&gt;
&lt;li&gt;What the comparator did on detecting 60 A: cut the drive, limit it or trigger some other protection.&lt;/li&gt;
&lt;li&gt;How the driver's throttle related to the controller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one more thing that matters: a log every 20 seconds is not enough to characterize short current peaks. I do not have the procedure used to diagnose them, and I am not going to say it was those logs. I also do not have range results from before and after the controller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconstruction
&lt;/h2&gt;

&lt;p&gt;So what I built is not a translation of the assembly. It is an experimental reimplementation in C, inspired by the documented principles, put against a simulator. It runs on the lab's Raspberry Pi 5.&lt;/p&gt;

&lt;p&gt;The simulator has a simple model of a DC motor, a battery with its internal resistance, and the mechanical load: mass, rolling and air. &lt;strong&gt;Everything physical is a modeling assumption&lt;/strong&gt;, not the original vehicle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Assumption&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vehicle with driver&lt;/td&gt;
&lt;td&gt;150 kg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Motor&lt;/td&gt;
&lt;td&gt;DC, 24 V, a top speed of about 48 km/h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Battery&lt;/td&gt;
&lt;td&gt;two 12 V lead-acid, 35 Ah, 0.03 Ω internal resistance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Track&lt;/td&gt;
&lt;td&gt;a 1 km lap: two 350 m straights and two 150 m curves taken at 25 km/h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver&lt;/td&gt;
&lt;td&gt;flat out on the straight, brakes for the curve and holds it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A lead-acid battery delivers less capacity the more current you draw from it. That is modeled with the Peukert effect, an exponent I do not know for those batteries. So I ran four: 1.0, which is no effect at all, and 1.1, 1.2 and 1.3. And I apply it to each current pulse, with no capacitor smoothing it: it is the assumption that least favors the controller.&lt;/p&gt;

&lt;p&gt;Three strategies, with the same track, the same driver, the same charged battery and the same parameters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No control:&lt;/strong&gt; the motor's duty cycle is whatever the throttle asks for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ramp:&lt;/strong&gt; the duty cycle rises at most from 0 to 100 % in three seconds; going down is immediate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ramp and a 60 A limit:&lt;/strong&gt; also, while the motor current is above 60 A, the duty cycle goes down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I did not model the “operating point after braking”: I do not know what it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;With Peukert 1.2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Until the battery is empty&lt;/th&gt;
&lt;th&gt;In 30 minutes&lt;/th&gt;
&lt;th&gt;Current peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No control&lt;/td&gt;
&lt;td&gt;27.6 km&lt;/td&gt;
&lt;td&gt;17.4 km&lt;/td&gt;
&lt;td&gt;254 A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ramp&lt;/td&gt;
&lt;td&gt;28.3 km&lt;/td&gt;
&lt;td&gt;17.3 km&lt;/td&gt;
&lt;td&gt;191 A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ramp and 60 A&lt;/td&gt;
&lt;td&gt;30.6 km&lt;/td&gt;
&lt;td&gt;16.9 km&lt;/td&gt;
&lt;td&gt;64 A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The peak is the motor current, which is what the battery delivers on each pulse; with the limit it goes a little above 60 A before the comparator pulls it back.&lt;/p&gt;

&lt;p&gt;The two distance columns measure different things, and that is the trade-off I wanted to see. When energy is the limit, the comparator makes the battery go 11 % farther. When the battery is enough for the whole race, no control wins: in 30 minutes it covers 2.8 % more, because it comes out of every curve faster.&lt;/p&gt;

&lt;p&gt;What the limit gains does not depend on Peukert alone. Without the effect, the limit still goes 6.5 % farther. About three quarters of that comes from losing less as heat, in the resistance of the battery, the motor and the wiring; the rest, from going a little slower, which costs less air and less braking. With Peukert 1.3, the advantage rises to 13.5 %. In other words: the direction of the result holds, but its size depends on an assumption I cannot confirm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ramp alone barely helps.&lt;/strong&gt; It goes 2.6 % farther than no control. Looking at the current millisecond by millisecond shows why: it lowers the peaks, but does not remove them. At the start, from 254 A to 191 A, because a three-second ramp reaches full duty before the vehicle picks up speed. And the peak is not only at the start: it comes back at every curve exit, twice a lap, when the driver accelerates from about 25 km/h. Without control that is about 133 A for five seconds; with the ramp, about 117 A. The comparator, on the other hand, acts on every one of them. That comes from how I modeled the driver, but it says something that made sense in 2001: the problem was not one start, it was all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The threshold is a trade-off too.&lt;/strong&gt; Moving the limit, with the same assumption:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Until the battery is empty&lt;/th&gt;
&lt;th&gt;In 30 minutes&lt;/th&gt;
&lt;th&gt;Time per lap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30 A&lt;/td&gt;
&lt;td&gt;41.1 km&lt;/td&gt;
&lt;td&gt;15.0 km&lt;/td&gt;
&lt;td&gt;118.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 A&lt;/td&gt;
&lt;td&gt;30.6 km&lt;/td&gt;
&lt;td&gt;16.9 km&lt;/td&gt;
&lt;td&gt;106.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 A&lt;/td&gt;
&lt;td&gt;28.5 km&lt;/td&gt;
&lt;td&gt;17.3 km&lt;/td&gt;
&lt;td&gt;104.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 30 A, the battery goes 49 % farther than with no control, but every lap takes 11 % longer than at 60 A. No threshold wins at everything: it depends on whether the race is decided by energy or by time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a 20-second log sees
&lt;/h2&gt;

&lt;p&gt;The simulator also takes a sample every 20 seconds, like the 2001 log, and compares it with what happens millisecond by millisecond.&lt;/p&gt;

&lt;p&gt;Without control, the current was above 60 A for 258 seconds of the 48 minutes the battery lasted. The 20-second log took 143 samples, and 12 of them landed above 60 A: roughly the same proportion, 8 % against 9 % of the time. But the highest it saw was 110 A, and the real peak was 254 A. A sample every 20 seconds can tell you that something happens now and then; it cannot tell you how high it goes or how long it lasts.&lt;/p&gt;

&lt;p&gt;It is the same problem as in &lt;a href="https://crtdrops.mx/notas/the-average-that-never-happened.html" rel="noopener noreferrer"&gt;the average that never happened&lt;/a&gt;: the measurement you have decides what you can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in twenty-five years, and what did not
&lt;/h2&gt;

&lt;p&gt;In 2001 we had an 8-bit microcontroller, assembly and a log every 20 seconds. Today I have a 64-bit processor on a board the size of a card, a simulator that runs a whole race in under a second, and the current of every millisecond. I can move an exponent and see how much the conclusion changes. In 2001 we could not.&lt;/p&gt;

&lt;p&gt;But the question is the same. The peaks that eat the battery, the trade-off between lasting and finishing first, the measurement that does not see what matters: computing power solved none of that.&lt;/p&gt;

&lt;p&gt;The code, the tests and the run's data are in &lt;a href="https://github.com/israel0negrete-crtdrops/electraton-2001" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;, with the assumptions together in one place for anyone to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same question
&lt;/h2&gt;

&lt;p&gt;In 2001 we were trying to get an electric vehicle to make better use of the energy available, with control decisions run by a microcontroller with limited resources. Twenty-five years later there are much more powerful processors, simulators, better instruments and analysis tools.&lt;/p&gt;

&lt;p&gt;But the fundamental question is still the same: how do we get the best possible performance without wasting the resources we have?&lt;/p&gt;

&lt;p&gt;What is interesting about going back to the Electratón was not to show that we can write a more complex controller today, but to find out how much we learned about a problem that is still relevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Have you gone back to something you built years ago? What still held up, and what could you no longer verify?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>embedded</category>
      <category>c</category>
      <category>electronics</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Ghosts in the fleet: knowing the IMEI is not knowing the device</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/ghosts-in-the-fleet-knowing-the-imei-is-not-knowing-the-device-202a</link>
      <guid>https://dev.to/crtdrops/ghosts-in-the-fleet-knowing-the-imei-is-not-knowing-the-device-202a</guid>
      <description>&lt;p&gt;In early November, in Mexico, families set up the &lt;em&gt;ofrenda&lt;/em&gt;, the Day of the Dead altar, to welcome those who are gone. Those visitors are expected: they have their place, their photo and their candle.&lt;/p&gt;

&lt;p&gt;A tracking platform can get visitors too, but uninvited ones: a device that shows up in two places at once, a session that stays open although nobody is on the other end anymore, a vehicle that according to the inventory should not be reporting, and reports.&lt;/p&gt;

&lt;p&gt;Before calling any of them a “clone,” it is worth knowing what kind of ghost it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The IMEI is a declaration
&lt;/h2&gt;

&lt;p&gt;Every tracking device introduces itself with an identifier: the IMEI, or the terminal identifier its protocol defines. The server reads it and, from that moment on, everything that arrives over that connection is attributed to that device.&lt;/p&gt;

&lt;p&gt;But the device declares that identifier itself. It is not cryptographic proof of who it is. That is why two connections presenting the same IMEI can mean very different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a legitimate reconnection;&lt;/li&gt;
&lt;li&gt;an earlier session that has not been closed yet;&lt;/li&gt;
&lt;li&gt;a wrong configuration;&lt;/li&gt;
&lt;li&gt;or two devices that really share an identifier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform has to tell them apart before deciding anything.&lt;/p&gt;

&lt;p&gt;I have no documented case in which it was confirmed that two physical devices were using the same IMEI at the same time, and I am not going to tell one. What is real is the architecture problem: the server can receive perfectly valid positions over a TCP connection and still attach them to the wrong identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three different ghosts
&lt;/h2&gt;

&lt;p&gt;They are worth separating, because each one leaves different evidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session ghost:&lt;/strong&gt; a connection the server still considers current, although there is no longer any communication with the device. It is the half-open connection from &lt;a href="https://crtdrops.mx/notas/an-open-socket-is-not-a-connected-device.html" rel="noopener noreferrer"&gt;the sockets note&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity ghost:&lt;/strong&gt; two devices, or two sessions, presenting the same identifier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory ghost:&lt;/strong&gt; a device that does report, but whose administrative link to a vehicle is wrong or out of date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first is a transport problem; the second, an identity problem; the third, an administrative one. Treating them the same way is how you end up calling a device that only lost coverage a clone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five signals, and what none of them proves alone
&lt;/h2&gt;

&lt;p&gt;This is what I would do today if I designed the mechanism from scratch. I cannot claim that every gateway I have worked with has these policies today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inventory.&lt;/strong&gt; Each identifier tied to a registered device, with its model, its administrative status and its assignment history. That way, a decommissioned device that keeps reporting is seen for what it is. But the inventory only knows what somebody registered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sessions.&lt;/strong&gt; A registry of active connections per identifier: the gateway instance, the session identifier, the connection time and the last valid activity. Two sessions with the same IMEI are a condition for review, not proof of a clone. It may simply be that the old one has not finished dying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geography.&lt;/strong&gt; Consecutive positions, compared using their original timestamps. If the same identifier shows up in two places no vehicle could travel between in that time, there is an inconsistency. But first you have to rule out GPS errors, historical data, wrong clocks and out-of-order messages: arrival time is not the time of the data, as in &lt;a href="https://crtdrops.mx/notas/the-right-position-at-the-wrong-time.html" rel="noopener noreferrer"&gt;the right position at the wrong time&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sequences.&lt;/strong&gt; In JT808, every message carries a sequence number that the device keeps incrementing. A gap, a repeat or a crossing gets attention, but on many devices the counter goes back to zero on a restart, and a retransmission usually repeats the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correlation.&lt;/strong&gt; I would not declare an IMEI cloned on a single anomaly. I would look for agreement: simultaneous sessions, incompatible trajectories, counters that cross, what the device declares about itself and what the inventory says. The output should be a grounded suspicion, not a verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detector
&lt;/h2&gt;

&lt;p&gt;To see the difference I built a detector that runs in the browser. It reads a synthetic day of twelve identifiers. Behind eight of them there is a single device:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a normal day;&lt;/li&gt;
&lt;li&gt;a session left half-open after two minutes without coverage;&lt;/li&gt;
&lt;li&gt;a wrong position, 18 km from where the vehicle is;&lt;/li&gt;
&lt;li&gt;twenty minutes without coverage on the highway, and the stored data arriving afterward, mixed with the new;&lt;/li&gt;
&lt;li&gt;a restart on the road: the counter goes back to zero and the time reads January 1, 2000 until the GPS gets a fix;&lt;/li&gt;
&lt;li&gt;retransmissions: each message has a 3 % chance of arriving twice;&lt;/li&gt;
&lt;li&gt;a firmware update, which restarts the device;&lt;/li&gt;
&lt;li&gt;a device the inventory has as decommissioned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Behind the other four there are two devices with the same identifier: a clone of a different model 145 km away; two of the same model driving around the same city; a replacement device configured with the old one's identifier, while the old one stays powered on in the warehouse; and a second, identical device that powers on for forty minutes 150 meters from the first, both parked.&lt;/p&gt;

&lt;p&gt;To keep it simple, here the device declares its model and firmware every time it starts a session. In JT808, depending on the protocol version, that travels in the registration, in the authentication or in a separate query.&lt;/p&gt;

&lt;p&gt;Two detectors read what the gateway would see: the sessions, each message's sequence number, the time it carries, the position, and the model and firmware the device declares. They never know which device sent what.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;naive&lt;/strong&gt; one calls it a clone on a single anomaly: a login while another session of the same identifier is open, an impossible jump between two consecutive positions in arrival order, or a sequence number that does not follow the previous one.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;correlated&lt;/strong&gt; one first rules out what has another explanation. With what is left, it speaks of a duplicate identity only if there is evidence from two different families: sessions and counter (which count as one, because they come from the same connections), geography, and declared identity.&lt;/p&gt;

&lt;p&gt;With the gateway set so the new session closes the old one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Naive&lt;/th&gt;
&lt;th&gt;Correlated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One device behind it (8): called a clone&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two devices behind it (4): flagged&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4, one only as “review”&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the gateway letting both sessions live, the figures are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the naive one falls
&lt;/h2&gt;

&lt;p&gt;Every false alarm has its explanation, and it is the one the correlated detector looks for first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The half-open session:&lt;/strong&gt; the device comes back with a new session. If the new login closes the old one, it is a single replacement, not flapping. If both live, the old one sends nothing more, and a silent session is not a second device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wrong position:&lt;/strong&gt; it is a stray point. It does not form a trajectory of its own; it is set aside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stored data:&lt;/strong&gt; on the default day, the 40 stored positions interleave in arrival order with the new ones and draw 7 impossible jumps. Ordered by the device's time, the jumps vanish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The restart:&lt;/strong&gt; the counter jumps once, to zero, and the new chain never crosses the old one again: that is not two counters. The positions with factory time stay off the map.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retransmissions:&lt;/strong&gt; the same number with the same content, seconds later, is the same message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The update:&lt;/strong&gt; it also restarts the device, so the naive one falls for the same reason. And the declared firmware changes once and never goes back: it is an update, not two devices taking turns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decommissioned device, on the other hand, the naive one does not even see: it has no communication anomaly at all. The correlated one flags it separately, as an inventory ghost, because it is not an identity problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the verdict does not say
&lt;/h2&gt;

&lt;p&gt;“Duplicate identity” means there are two devices with the same identifier. It does not say whether one is a clone or someone misconfigured a device: in the replacement case, the evidence is the same as for a clone. That is found out in the field, not on the server.&lt;/p&gt;

&lt;p&gt;And the forty-minute device stays at “review.” The sessions flip back and forth, and there are two counters crossing, but both devices are parked 150 meters apart, closer than the kilometer the detector tolerates as GPS noise, and with the same model and the same firmware: there is no geography or declared identity to confirm it. With that evidence, “review” is the honest answer. A person has to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this detector does not prove
&lt;/h2&gt;

&lt;p&gt;I made the twelve cases on purpose, and I chose the detector's thresholds while looking at them. The code's tests run fifty different days, and in all of them the correlated detector gives the expected answer; that is by construction, not a measure of how it would do on real data. Real data would bring cases that are not here: a clone that also imitates the counter or the firmware, clocks that drift little by little, devices that lose messages.&lt;/p&gt;

&lt;p&gt;Each control case has a single event, and the thresholds sit just above it. With a single device, it would not take much to reach “review”: three restarts in a day or, with the policy that closes the old session, five one-minute outages in an hour.&lt;/p&gt;

&lt;p&gt;It also shows how much depends on each threshold. With that same policy, if flapping is declared at a single session replacement per hour, the half-open session, the restart and the update move to “review.” If the retransmission window is set to zero, the retransmissions look like a second counter.&lt;/p&gt;

&lt;p&gt;To see each case from the inside, with its map, its sessions, its counter and the timeline of the evidence, there is the &lt;a href="https://crtdrops.mx/identidad/" rel="noopener noreferrer"&gt;ghost detector&lt;/a&gt;: it runs in the browser and sends nothing. You can move the thresholds and the gateway policy, and see which cases change their verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  No anomaly is enough alone
&lt;/h2&gt;

&lt;p&gt;On a tracking platform, knowing the IMEI does not necessarily mean knowing the device. That is why identification, session, location and inventory have to be kept apart.&lt;/p&gt;

&lt;p&gt;An impossible jump on the map is an anomaly. Two connections with the same IMEI are an anomaly. An unexpected sequence can be one too. But none of them, on its own, proves that a cloned device exists.&lt;/p&gt;

&lt;p&gt;A good ghost detector should not find clones where there are only communication errors. It has to gather enough evidence to tell a duplicate identity from a session that has not finished dying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Have you seen two devices answer to the same identifier? What finally told you which one was which?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>networking</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>The query that took too long</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Fri, 06 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-query-that-took-too-long-gnp</link>
      <guid>https://dev.to/crtdrops/the-query-that-took-too-long-gnp</guid>
      <description>&lt;p&gt;The query endpoints of a transactional platform were returning HTTP 504: timeouts, with pagination and without it. The table was large—more than eight million rows in 2026 alone—and the first temptation was to think that explained everything: more rows, more time, more server.&lt;/p&gt;

&lt;p&gt;It did not. The problem was not reading a large table, but how MySQL joined, filtered and sorted the data to answer that query. That is what this note is about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The figures in the examples are illustrative: the plans in the &lt;a href="https://crtdrops.mx/explain/" rel="noopener noreferrer"&gt;plan reader&lt;/a&gt; are hypothetical, and the growth model is a model, not a measurement. From the case come the 8,040,017 records; the 1,792 and 54,898 rows, &lt;code&gt;EXPLAIN&lt;/code&gt; estimates (the latter from the variant with hints explained below); the 17.05 s it took to create the index; and the 4.5 ms that &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; measured in a lab test, not in production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A 504 does not say where the problem is
&lt;/h2&gt;

&lt;p&gt;A 504 comes from the load balancer or proxy in front of the application when it gets tired of waiting. It does not say whether the time went into the database, the network, the application or building the response.&lt;/p&gt;

&lt;p&gt;So the query was analyzed apart from the rest of the application, with the SQL that actually reached the database. In this case it was hand-written SQL, not generated by an ORM, but the rule is the same: to diagnose, you have to see what MySQL runs, not what you think it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table and the query
&lt;/h2&gt;

&lt;p&gt;MySQL 8.0.46 on Ubuntu. Transactions live in one table per year, from 2015 to 2030; the 2026 table had 8,040,017 records when we ran the tests. Around it are lookup tables: operations, products, providers and the inbound and outbound addresses.&lt;/p&gt;

&lt;p&gt;The query fetches one operation's transactions for an operational report: date, time, masked phone number, identifiers, status and processing time, with the names that come from the lookup tables, ordered by date, from oldest to newest, in pages of 50. One operation had between 52,000 and 55,000 estimated rows, in a table of more than eight million.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan that estimated fewer rows
&lt;/h2&gt;

&lt;p&gt;In the &lt;code&gt;EXPLAIN&lt;/code&gt; of the original query, the transactions table's row showed the &lt;code&gt;IdProductos&lt;/code&gt; index, about 1,792 estimated rows and &lt;code&gt;Using where&lt;/code&gt;. And the plan carried &lt;code&gt;Using temporary&lt;/code&gt; and &lt;code&gt;Using filesort&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At first sight, 1,792 looks small. But the rows column in &lt;code&gt;EXPLAIN&lt;/code&gt; is what MySQL expects to read each time it runs that step, and the transactions table sat inside the join: that step runs once for every row the previous step hands it. &lt;code&gt;Using where&lt;/code&gt; means it keeps only the rows that meet the condition, and a products index does not lead to one operation's rows in date order. A small estimate at one step does not tell you how much work the whole plan is.&lt;/p&gt;

&lt;p&gt;Then there were the two flags. In tabular &lt;code&gt;EXPLAIN&lt;/code&gt;, when they appear together, &lt;code&gt;Using temporary&lt;/code&gt; and &lt;code&gt;Using filesort&lt;/code&gt; are written on the row of the first non-constant table in the plan, but they are not about that table: they say the result of the join is stored separately and sorted. They are not errors: sorting or grouping a few rows is reasonable. Here, according to the plan, they meant that to return 50 rows in date order MySQL had to gather all of the operation's rows, store them separately and sort them before cutting: about 52,000 to 55,000 estimated rows to return 50.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index that follows the query's order
&lt;/h2&gt;

&lt;p&gt;The query filters by operation and orders by date. An index with those two columns in that order lets MySQL find the operation's rows already sorted, read them in order and stop as soon as the page is full, as long as the transactions table is the first in the join. It was a composite index, &lt;code&gt;idx_transacciones_operacion_fecha (IdOperaciones, FechaHora)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Creating it on the 2026 table took 17.05 s in our measurement. Since the transactions are split by year, the index was also applied to the other yearly tables.&lt;/p&gt;

&lt;p&gt;To evaluate a plan that started from the transactions, we tested a variant with &lt;code&gt;FORCE INDEX (idx_transacciones_operacion_fecha)&lt;/code&gt; and &lt;code&gt;STRAIGHT_JOIN&lt;/code&gt;. Both are investigation tools: &lt;code&gt;FORCE INDEX&lt;/code&gt; makes MySQL use that index whenever it can and &lt;code&gt;STRAIGHT_JOIN&lt;/code&gt; forces the tables to be joined in the order written. They are for comparing plans; leaving them in the code freezes a decision the optimizer can no longer correct when the data changes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;EXPLAIN&lt;/code&gt; of that variant estimated about 54,898 rows up front, with no temporary table or filesort, and the lookup tables were joined with &lt;code&gt;eq_ref&lt;/code&gt;: one exact lookup by key per row. &lt;strong&gt;The plan that estimated more rows did the whole job better.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring: 4.5 ms, and what it leaves out
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; is the plan before executing: what MySQL computes from its statistics. To know how long it takes you have to run it, and that is what &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is for: it runs the query and reports, for each step, the time, the actual rows and the loops.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;LIMIT 50&lt;/code&gt;, the &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; of the variant with the hints recorded between 4.37 and 4.50 ms. That is the time to run the plan inside MySQL, in a lab test. It is not an end-to-end measurement of the API: it leaves out the network, the application and sending and serializing the response. Nor does it hold for the unpaginated query or for other filters, which have plans of their own.&lt;/p&gt;

&lt;p&gt;It is not a production result either. When the tests ended, the change still had to be applied to the Node.js service and validated there. And it does not show the hints can be removed: without &lt;code&gt;FORCE INDEX&lt;/code&gt; and &lt;code&gt;STRAIGHT_JOIN&lt;/code&gt;, we do not know whether MySQL would pick the same plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do not know
&lt;/h2&gt;

&lt;p&gt;There are questions this story does not answer, and it is worth saying so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Production.&lt;/strong&gt; There is no confirmation the change was deployed, nor a comparative measurement between development and production. We do know that the real volume and the distribution of records per operation mattered to the optimizer: an index that is good for one pattern may not be for another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The plan without hints.&lt;/strong&gt; We did not check which plan MySQL picks with the composite index and without &lt;code&gt;FORCE INDEX&lt;/code&gt; or &lt;code&gt;STRAIGHT_JOIN&lt;/code&gt;. Before deciding whether they stay in the code, that is what has to be measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locks.&lt;/strong&gt; There is no evidence that the query blocked writes or stalled transactions. A slow query and a lock are different problems, and they should not be confused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the index costs on writes.&lt;/strong&gt; Every index is updated on every insert, every delete and whenever its columns change. We did not measure it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When the table grows
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://crtdrops.mx/explain/" rel="noopener noreferrer"&gt;plan reader&lt;/a&gt; includes a model to see the shape of the problem. It is a row count with assumed microseconds per step, not a MySQL measurement: it is for comparing curves, not for predicting milliseconds. That is why, for the composite index, the model gives tenths of a millisecond and the case's lab measurement gave 4.5 ms.&lt;/p&gt;

&lt;p&gt;With a table of 8,040,017 rows, an operation of 54,898—the case's estimate—and the transactions table first, for the first page of 50 the model gives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How it is resolved&lt;/th&gt;
&lt;th&gt;Rows touched&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No useful index: scans everything and sorts&lt;/td&gt;
&lt;td&gt;8,040,267&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index on operation only: finds them all and sorts&lt;/td&gt;
&lt;td&gt;55,148&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(operation, date) index: reads in order and stops&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each one grows with something different. The first, with the table: twice the rows, twice the work. The second, with the operation, even if the table does not grow. The third, only with the page requested: for page 100, with &lt;code&gt;OFFSET&lt;/code&gt;, it also reads the previous 4,950, joins the lookup tables to all of them and touches 30,000 rows. Paginating by the last date and id seen, with the id also in the &lt;code&gt;ORDER BY&lt;/code&gt;, instead of skipping rows, avoids reading them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do from day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identify the critical queries and their access pattern:&lt;/strong&gt; what they filter, what they join, in what order they return, how they paginate and at what volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design indexes around how the data is retrieved,&lt;/strong&gt; not only around the relationships between tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate transaction processing from analytical queries and reports,&lt;/strong&gt; so they do not compete for the same resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure latency, review plans periodically and test with representative volumes.&lt;/strong&gt; A query is not optimized because it answered fast in an isolated test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To read a plan of your own, compare the before and after, or see how the work grows with the table, there is the &lt;a href="https://crtdrops.mx/explain/" rel="noopener noreferrer"&gt;MySQL plan reader&lt;/a&gt;: it runs in the browser, tells what MySQL estimates apart from what it measured, and what you paste never leaves your machine.&lt;/p&gt;

&lt;p&gt;The lesson was not that &lt;code&gt;FORCE INDEX&lt;/code&gt; and &lt;code&gt;STRAIGHT_JOIN&lt;/code&gt; should always be used, but that the execution plan has to be checked against real data, contrasting its estimates with the actual execution.&lt;/p&gt;

&lt;p&gt;It was not simply a matter of creating an index. It took understanding how MySQL joined, filtered and sorted the data.&lt;/p&gt;

&lt;p&gt;A database does not get slow only because it grows. It can also get slow because our queries keep asking it to do more work than necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;What's the query plan that fooled you the most, and what made you look twice? If you want to read your own plans side by side, before and after, the tool is at &lt;a href="https://crtdrops.mx/explain/" rel="noopener noreferrer"&gt;crtdrops.mx/explain&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>An open socket is not a connected device</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Tue, 03 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/an-open-socket-is-not-a-connected-device-5dl2</link>
      <guid>https://dev.to/crtdrops/an-open-socket-is-not-a-connected-device-5dl2</guid>
      <description>&lt;p&gt;An operator sends a command to immobilize a vehicle. The server looks up the device's connection, writes the command to the socket, and the write succeeds. That does not show the device received it, much less that it carried it out.&lt;/p&gt;

&lt;p&gt;In vehicle tracking, the TCP connection is only a means of communication. It is not a guarantee of availability. That is what this note is about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The figures in the examples are illustrative: they come from a simulator with an assumed fleet, not from measurements of our gateways or of any carrier.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Connections that die without notice
&lt;/h2&gt;

&lt;p&gt;We have worked with receiving gateways for GPS devices, telemetry and vehicle cameras. With JT808 and other tracking protocols, the device opens a persistent TCP connection and keeps it to send positions, alarms and other messages. How it behaves depends on the protocol and the manufacturer.&lt;/p&gt;

&lt;p&gt;With mobile devices, that connection can stop working without the server receiving a close: the vehicle loses coverage, switches networks, or the carrier's NAT forgets the connection for being idle. The server keeps a socket that looks open, even though the device no longer has connectivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to find out
&lt;/h2&gt;

&lt;p&gt;There are three mechanisms to learn that a connection died, and they are not equivalent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application activity:&lt;/strong&gt; how long it has been since the device's last valid message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The protocol's heartbeat:&lt;/strong&gt; whether the device keeps sending its periodic presence messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP keepalive:&lt;/strong&gt; the operating system's mechanism to detect connections that stopped responding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the &lt;a href="https://crtdrops.mx/conexiones/" rel="noopener noreferrer"&gt;simulator&lt;/a&gt;, a fleet of 2,000 devices sends a position every 30 seconds while moving and a heartbeat every five minutes while parked, and each device loses coverage about four times a day. In one day, 7,762 connections die without notice. This is how long the server takes to find out, at the median:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With three missing heartbeats, 12.8 minutes.&lt;/li&gt;
&lt;li&gt;With TCP keepalive at Linux's defaults (two hours idle and nine probes), 2.2 hours.&lt;/li&gt;
&lt;li&gt;If, on top of that, the same device's new session closes the old one, 10.3 minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system's keepalive, at its factory values, is no use for this, and on top of that it is off by default on each socket. And none of them is instant: during those minutes, the socket looks open.&lt;/p&gt;

&lt;p&gt;I do not have a single heartbeat interval documented for our whole fleet: it depends on the protocol, the manufacturer and the configuration. Nor would I attribute a NAT expiry time to a carrier without having measured it. In the simulator they are assumptions, and you can change them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a heartbeat costs
&lt;/h2&gt;

&lt;p&gt;A small heartbeat does not cost only its bytes. A 20-byte heartbeat, with the TCP/IP headers, its response and the ACK, is about 160. In the simulator, the fleet spends 1.22 MB a month per device just staying connected; counting only the heartbeat and its response it would look like 0.30. In a large fleet, that difference adds up.&lt;/p&gt;

&lt;p&gt;Stretching the heartbeat to save data has a limit: the NAT. If the heartbeat is 15 minutes and the NAT forgets after 10, every heartbeat from a parked device hits a connection that no longer exists and forces a reconnection. In the simulator, the data drops by a quarter, from 1.22 to 0.91 MB, and the connections that die without notice go from 7,762 to more than 170,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sent is not received
&lt;/h2&gt;

&lt;p&gt;The command case is the most delicate. That is why I would distinguish four states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Command generated.&lt;/li&gt;
&lt;li&gt;Command sent to the connection.&lt;/li&gt;
&lt;li&gt;Command confirmed by the device.&lt;/li&gt;
&lt;li&gt;Action executed and verified.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Going from the second to the third takes a confirmation from the protocol; from the third to the fourth, where it applies, an independent check of the final state.&lt;/p&gt;

&lt;p&gt;On the simulator's day, 988 commands are sent. Of them, 24 are written to a socket that was already dead: the server "sent" them and they never arrived. If "sent" counted as done, those would be 24 false successes. With a lifecycle that waits for the confirmation, eight are resent over the new session and 16 expire unconfirmed, in plain view of whoever sent them. And if the heartbeat goes past the NAT, the commands written to dead sockets rise to 322.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two sessions from the same device
&lt;/h2&gt;

&lt;p&gt;A device loses communication, reconnects and opens a new session before the server detects that the previous one no longer works. If the system manages connections only by socket, it can end up with two sessions from the same device.&lt;/p&gt;

&lt;p&gt;My approach is a session registry per device, with a session identifier or generation. When a new, authenticated, valid session arrives, it replaces the previous one under an explicit policy, and the old one is invalidated and closed in a controlled way. You also have to keep a late close of the old session from accidentally wiping out the reference to the new one. I do not present this as a failure we suffered, but as a condition the design has to resolve.&lt;/p&gt;

&lt;p&gt;The simulator compares the two registries. In its day, devices reconnect 4,579 times with the old session still open. With a registry by socket, which registers the device with every message and deletes the entry when any of its sessions closes, 4,566 of those late closes wipe out the new session. The device is still connected and the system takes it as disconnected until its next message: 335 device-hours in the day. If the registry were only updated at authentication, the device would stay "disconnected" until it reconnected again: 20,801 device-hours, and 429 commands would expire without finding a session. With a registry by generation, none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Several instances
&lt;/h2&gt;

&lt;p&gt;With several gateways, each device may be connected to a different instance. Knowing its IMEI is not enough: you have to know which instance holds its connection. I would design a distributed session registry with the device's identifier, the instance, the session's identifier and generation, the last valid activity, and its state and validity.&lt;/p&gt;

&lt;p&gt;The command is routed to that instance, and the instance checks that it still holds the session before transmitting it. I would not send it to every gateway. And you have to account for the device moving to another instance while the command is in transit. The registry finds the connection, but it does not replace the protocol's confirmation or guarantee that the command was executed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restart that becomes a second failure
&lt;/h2&gt;

&lt;p&gt;When a gateway restarts, the sessions in that process are cut. What happens next depends on the devices: some reconnect at fixed intervals, others with other strategies, and it cannot always be controlled from the platform. I have no documented incident of a mass reconnection, or a figure for devices that reconnected at once. What I do consider essential is that recovery must not turn into a second failure.&lt;/p&gt;

&lt;p&gt;And three things have to be told apart: an accepted TCP connection, an authenticated device and a session ready to receive commands. In the simulator, the gateway is down for 15 seconds; it accepts 1,000 connections a second with a queue of 511 (the one Node.js uses unless told otherwise), authenticates 100 devices a second and initializes 200 sessions. With 2,000 devices retrying together every 30 seconds, 2,000 attempts arrive in one second. The queue fills up: Linux drops 3,323 SYNs, which the devices resend; 900 attempts are abandoned after waiting too long, and every session is ready for commands after 79 seconds. With a random wait of up to a minute, the peak is 74 attempts a second, nothing is dropped and all are ready after 60 seconds, a time that cannot go below that wait: the last device does not try before it. With 5,000 devices the gap grows: 195 seconds against 64, and with a fixed interval 27,363 SYNs are dropped.&lt;/p&gt;

&lt;p&gt;What matters is not how many attempts arrive per second, but how long the system takes to recover every session. In a new design I would use staggered restarts where the architecture allows it, load distribution, and random waits on reconnection where the firmware supports them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits you do not see
&lt;/h2&gt;

&lt;p&gt;I have no documented incident from running out of file descriptors, memory or ports in our tracking gateways. But they are part of what has to be sized: the available descriptors, memory per connection, event loop usage, TCP accept queues and socket states. I would not assume a server can handle thousands of connections just because it has memory and CPU to spare.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do today
&lt;/h2&gt;

&lt;p&gt;In our current architectures we mainly use Node.js and NestJS for TCP reception, with specialized services per protocol, and RabbitMQ to decouple reception from processing. If I designed a gateway for thousands of devices from scratch today:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I would separate four responsibilities.&lt;/strong&gt; Transport: sockets, connections, disconnections, timeouts and bytes. Protocol: identifying devices, parsing frames, validating messages and handling confirmations. Sessions: the device's identity, its current connection and the instance holding it. Messaging and processing: handing telemetry to the rest of the platform without blocking reception.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I would give commands a lifecycle of their own,&lt;/strong&gt; with unique identifiers, execution states, expirations and confirmations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I would measure from the start&lt;/strong&gt; how many connections there are, how many are really active, how many reconnections happen per minute, how long it takes to detect a dead session and how many commands are left unconfirmed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I would run load tests that go beyond sending positions:&lt;/strong&gt; thousands of persistent connections, silent disconnections, duplicate sessions and gateway restarts.&lt;/p&gt;

&lt;p&gt;To try it with another fleet, another heartbeat, another NAT or another gateway, there is the &lt;a href="https://crtdrops.mx/conexiones/" rel="noopener noreferrer"&gt;connection simulator&lt;/a&gt;: it runs in the browser, and everything it says is a model, not a measurement.&lt;/p&gt;

&lt;p&gt;An open socket does not show that the device is effectively connected. A command sent does not show that it arrived. And an acknowledgment of receipt does not necessarily show that the action was carried out. When we manage many devices, reliability depends less on how many connections we can open and more on our ability to know the real state of each session and to recover correctly when communications fail.&lt;/p&gt;

&lt;p&gt;The real challenge is not keeping thousands of sockets open, but managing thousands of devices whose connectivity can change at any moment.&lt;/p&gt;

&lt;p&gt;If you build or test platforms like this one, that is the ground my guide &lt;strong&gt;Virtual Fleets&lt;/strong&gt; covers: silence and disconnection, the reconnection storm, and the acknowledgment that is not one, with a virtual fleet generator to produce that traffic against your own platform. Chapter 4, on the receiving side, is free as a PDF:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://israelnegretelepe.gumroad.com/l/a-read-is-not-a-frame" rel="noopener noreferrer"&gt;israelnegretelepe.gumroad.com/l/a-read-is-not-a-frame&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual Fleets&lt;/strong&gt; — &lt;a href="https://israelnegretelepe.gumroad.com/l/virtual-fleets" rel="noopener noreferrer"&gt;israelnegretelepe.gumroad.com/l/virtual-fleets&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How does your platform know a device is really connected: the last message, a protocol heartbeat, or TCP keepalive? And when you send a command, what does your system show between "sent" and "confirmed"? If you want to try your own heartbeat, NAT and gateway numbers, the simulator is at &lt;a href="https://crtdrops.mx/conexiones/" rel="noopener noreferrer"&gt;crtdrops.mx/conexiones&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>networking</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The lab's ofrenda: what didn't survive October</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-labs-ofrenda-what-didnt-survive-october-5d8h</link>
      <guid>https://dev.to/crtdrops/the-labs-ofrenda-what-didnt-survive-october-5d8h</guid>
      <description>&lt;p&gt;In Mexico, from October 31 to November 2, families set up an &lt;em&gt;ofrenda&lt;/em&gt; for the Day of the Dead: an altar with a photo of the one who died, orange cempasúchil marigolds, &lt;em&gt;pan de muerto&lt;/em&gt;, candles, and whatever that person loved in life. They also write &lt;em&gt;calaveritas&lt;/em&gt;, short mocking verses in which La Catrina, the elegant skeleton lady, comes to take someone away.&lt;/p&gt;

&lt;p&gt;A lab has its dead too. My lab, in October 2026, was two machines in an office: an Intel N100 mini PC and a Raspberry Pi 5. Not every experiment was meant to survive. Some died because we found something better, some because the measurements contradicted what we expected, and some simply ran their course. But when what happened is documented properly, none of them disappears completely.&lt;/p&gt;

&lt;p&gt;This is the ofrenda. Each one gets a death certificate: the date, a technical cause of death, and what it leaves us. And its calaverita, because there is no ofrenda without one. The verses are written for English, not translated: the Spanish ones rhyme their own way. The humor does not hide the results: every figure comes from a published measurement or from its log.&lt;/p&gt;

&lt;p&gt;One thing first. I have no record of any hardware dying in October: no SD card, no power supply, no sensor. We did not plant an electronic corpse to fill the altar.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;pi&lt;/code&gt; user
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Died&lt;/strong&gt; on October 3, on the Raspberry Pi 5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause of death:&lt;/strong&gt; retirement. That day both machines became dedicated to the lab, with a user of their own, &lt;code&gt;edgelab&lt;/code&gt;, and a key of their own. Before the factory account was closed, the whole lab that lived in it was copied to &lt;code&gt;edgelab&lt;/code&gt; and checked by hash. Then the old copy was deleted, and &lt;code&gt;pi&lt;/code&gt; had its password locked and its keys removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Epitaph:&lt;/strong&gt; &lt;em&gt;Here rests an account that served its purpose. Its experiments live on in edgelab.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it leaves us:&lt;/strong&gt; it did not die of a failure; the lab changed. Settings that no longer match the architecture have to be retired too. And in this order: copy and check first, then delete.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;La Catrina knocked on the Pi, very polite,&lt;br&gt;
and told the old &lt;code&gt;pi&lt;/code&gt; user it was time to rest:&lt;br&gt;
"edgelab has your files, every hash checked right;&lt;br&gt;
leave your keys at the door, and I'll handle the rest."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  OpenVINO's promise on the N100's CPU
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Died&lt;/strong&gt; on October 3, on the N100. Confirmed on October 7.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause of death:&lt;/strong&gt; measurement. OpenVINO is Intel's runtime for its own hardware, and on an Intel mini PC it looked like the obvious choice. With the precision pinned to f32, it was 11 % to 22 % slower than ONNX Runtime on the three models I measured: MobileNetV2, ResNet18 and YOLO26n. Against its mode for many requests at once, ONNX Runtime with four processes stayed ahead by 14 to 20 %.&lt;/p&gt;

&lt;p&gt;It did not die completely. On the N100's integrated GPU, OpenVINO does win: with YOLO26n, 39 % more inferences per second than ONNX Runtime on the CPU. But it wins in FP16, a precision it picks on its own. With it, detections lose confidence: in one image, a person went from 0.829 to 0.696. What died was the promise that on this machine's CPU, choosing it was enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Epitaph:&lt;/strong&gt; &lt;em&gt;Performance is proven by measurements, not by a tool's promises.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it leaves us:&lt;/strong&gt; pin the precision, then read back the one that actually stuck. The full measurement is in &lt;a href="https://crtdrops.mx/notas/openvino-was-not-faster.html" rel="noopener noreferrer"&gt;the OpenVINO note&lt;/a&gt;, and the raw data is at &lt;a href="https://crtdrops.mx/datos/" rel="noopener noreferrer"&gt;crtdrops.mx/datos&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;OpenVINO came promising speed,&lt;br&gt;
so La Catrina brought a stopwatch to the race;&lt;br&gt;
with f32 pinned, it could not take the lead,&lt;br&gt;
and ONNX Runtime crossed the line in first place.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The assistant's scheduled tasks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Died&lt;/strong&gt; on October 9.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause of death:&lt;/strong&gt; permissions. There were four automatic tasks. Two were read-only: one went through the dev.to conversations every weekday morning, and one measured the numbers every Monday. The other two, one-off runs, added the closing question to two notes that were already published. They kept getting stuck asking for permissions, and there was no handy place to approve them. Besides, almost everything they did needed my "yes" anyway.&lt;/p&gt;

&lt;p&gt;A routine I start myself every morning replaced them. The only thing left automatic is what does not need my permissions: dev.to's scheduled posts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Epitaph:&lt;/strong&gt; &lt;em&gt;An automation that needs constant supervision is no longer truly autonomous.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it leaves us:&lt;/strong&gt; before automating something, ask who unblocks it when it gets stuck. If the answer is "me, all the time," it is not automatic yet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Four little tasks, on time and polite,&lt;br&gt;
got up early and went off to work;&lt;br&gt;
they asked for permission, then sat tight,&lt;br&gt;
till La Catrina got tired and took them, with a smirk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TradingAgent's original strategy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Died&lt;/strong&gt; on October 10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause of death:&lt;/strong&gt; it failed the test. TradingAgent is an agent of my own for spot crypto trading; it is not a product and not a recommendation. It was validated with a protocol written before looking at the data: it is fitted on 2018 to 2022, tested on 2023 to September 2026, and it has to meet five criteria. The original strategy, exactly as it ran, gave this (the figures come from its log, which is not published):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Fit 2018–2022&lt;/th&gt;
&lt;th&gt;Test 2023–Sep 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Annual return&lt;/td&gt;
&lt;td&gt;+3.0 %&lt;/td&gt;
&lt;td&gt;+0.4 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum drawdown&lt;/td&gt;
&lt;td&gt;2.1 %&lt;/td&gt;
&lt;td&gt;3.6 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MAR (annual return over maximum drawdown)&lt;/td&gt;
&lt;td&gt;1.48&lt;/td&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pairs with a positive result&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;td&gt;6 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reality Check&lt;/td&gt;
&lt;td&gt;97, robust&lt;/td&gt;
&lt;td&gt;63, marginal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol criteria&lt;/td&gt;
&lt;td&gt;meets all 5&lt;/td&gt;
&lt;td&gt;fails 3: MAR, Reality Check and at least 7 of 10 positive pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The edge of 2018 to 2022 did not hold outside that window. With double the costs, the test drops to +0.1 % a year. In &lt;a href="https://crtdrops.mx/realitycheck/" rel="noopener noreferrer"&gt;Reality Check&lt;/a&gt;'s luck test, 29 % of the resamples lose. And with my 15 % tolerance for maximum drawdown, none of the 18 configurations of the second phase gave an agent.&lt;/p&gt;

&lt;p&gt;A positive result is not enough: a strategy has to justify the risk it takes. At +0.4 % a year, it does not.&lt;/p&gt;

&lt;p&gt;Its successor wanders like a restless soul. One of the second phase's families, scaled not to drop more than 15 %, passed the test, though as weaker evidence, because that period had already been used. But running it one day late, or rebalancing on another day of the week, was enough to push its drawdown past 15 %: of the five ways of running it that I tried, only the chosen one stayed under. That is not good enough for real money. It has been paper trading since October 12, and nobody buries it or prays for it until January 11, 2027, when it is decided whether it moves on to a test network, still without real money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Epitaph:&lt;/strong&gt; &lt;em&gt;Ruling out a hypothesis is a research result too.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it leaves us:&lt;/strong&gt; write the criteria before seeing the test, and do not move them afterward.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The trading agent, careful and neat,&lt;br&gt;
met La Catrina with data it had never seen:&lt;br&gt;
four tenths of a percent a year, the whole receipt.&lt;br&gt;
"That won't even buy the candles," she said. "Too lean."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Laplacian and the minimum distance
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Died&lt;/strong&gt; on October 26, on the N100. They share one portrait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause of death:&lt;/strong&gt; noise. In the &lt;a href="https://crtdrops.mx/fantasmas/" rel="noopener noreferrer"&gt;ghosts experiment&lt;/a&gt;, a bright disk moves over a uniform background and the job is to say where it is. These were two of the filters of the 2001 eye that I rewrote in C: the Laplacian and a minimum-distance edge test, each with a fixed threshold. On the clean scene they worked: 1.1 px and 0.3 px of median error. With a pinch of noise—Gaussian σ 10 or 1 % impulse—they lost the disk in all 20 frames of each case, with and without the echo canceler. The median filter with the canceler stayed, picked after seeing the results as the one with the lowest worst case: on the 72 frames of the comparison, 0.1 px of median error, and 7.8 px on the hardest ghost, Gaussian σ 50.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Epitaph:&lt;/strong&gt; &lt;em&gt;An algorithm is not judged by its best result, but by how it holds up under real conditions.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it leaves us:&lt;/strong&gt; publish the variants that fail too. All ten are in &lt;a href="https://github.com/israel0negrete-crtdrops/ghosts-in-the-frame" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;, the bad ones included.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Laplacian and its friend, the minimum-distance test,&lt;br&gt;
drew beautiful edges on a scene with no flaw;&lt;br&gt;
a sprinkle of noise, sigma ten, did the rest,&lt;br&gt;
and the fixed threshold dug the grave for them all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Gemma as an eye
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Died&lt;/strong&gt; on October 27, on the N100.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause of death:&lt;/strong&gt; it always gave the same answer. In the same experiment, Gemma 3 4B, running locally, answered (320, 240) on all 72 images, the clean ones too: the exact center of the image. Its median error was 177.8 px. On the same machine and with the same images, Qwen2.5-VL 3B found the disk within 1.3 px on the 37 frames where it gave a full box.&lt;/p&gt;

&lt;p&gt;It died for that prompt: I tried only one, and another might do better. And it did not die completely. Gemma still writes the &lt;a href="https://crtdrops.mx/turno/" rel="noopener noreferrer"&gt;night shift&lt;/a&gt; report every morning, which is the job where we did measure where it goes wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Epitaph:&lt;/strong&gt; &lt;em&gt;A consistent answer is not necessarily a correct answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it leaves us:&lt;/strong&gt; before believing a model, check whether its answer changes when the image changes. If it does not, it is not looking.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Gemma, who writes the report before dawn,&lt;br&gt;
wanted to be an eye and find the disk too;&lt;br&gt;
seventy-two times it pointed dead center, and yawned,&lt;br&gt;
while the disk, somewhere else, laughed the whole night through.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The guest who came back: the Raspberry Pi 5
&lt;/h2&gt;

&lt;p&gt;Not as one of the dead, but as a guest who came back from the other side.&lt;/p&gt;

&lt;p&gt;On October 27 at 20:06, the power went out at the office. The Pi lost power and rebooted. Nobody touched it: the heartbeat came back on its own, and the night shift, which starts at 22:00, came out complete that same night: 100 % of the expected heartbeats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it leaves us:&lt;/strong&gt; &lt;em&gt;resilience is not avoiding every interruption, but being able to recover from it.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The lights went out, the office went black,&lt;br&gt;
and La Catrina came for the Pi in the night;&lt;br&gt;
the power returned, it booted right back,&lt;br&gt;
and La Catrina pulled the night shift till light.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The full certificate
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The departed&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;What it leaves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The &lt;code&gt;pi&lt;/code&gt; user&lt;/td&gt;
&lt;td&gt;Oct 3&lt;/td&gt;
&lt;td&gt;Retirement&lt;/td&gt;
&lt;td&gt;Retire what no longer fits, copying and checking first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenVINO on the N100's CPU&lt;/td&gt;
&lt;td&gt;Oct 3&lt;/td&gt;
&lt;td&gt;11 to 22 % slower in f32&lt;/td&gt;
&lt;td&gt;Measure instead of assuming; pin and read the precision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The scheduled tasks&lt;/td&gt;
&lt;td&gt;Oct 9&lt;/td&gt;
&lt;td&gt;Stuck asking for permissions&lt;/td&gt;
&lt;td&gt;If it needs constant supervision, it is not autonomous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TradingAgent's original strategy&lt;/td&gt;
&lt;td&gt;Oct 10&lt;/td&gt;
&lt;td&gt;+0.4 % a year in the test&lt;/td&gt;
&lt;td&gt;Ruling out a hypothesis is a result too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Laplacian and the minimum distance&lt;/td&gt;
&lt;td&gt;Oct 26&lt;/td&gt;
&lt;td&gt;Lost the disk with little noise&lt;/td&gt;
&lt;td&gt;Judged by how it holds up, not by its best case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma as an eye&lt;/td&gt;
&lt;td&gt;Oct 27&lt;/td&gt;
&lt;td&gt;The center, on all 72 images&lt;/td&gt;
&lt;td&gt;Consistent is not correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Raspberry Pi 5 (guest)&lt;/td&gt;
&lt;td&gt;Oct 27&lt;/td&gt;
&lt;td&gt;Power outage; came back on its own&lt;/td&gt;
&lt;td&gt;Recover, rather than avoid every interruption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In this ofrenda we do not celebrate the mistakes: we celebrate what we learned by recognizing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;What did your lab bury this year, and what did it leave you?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>raspberrypi</category>
      <category>machinelearning</category>
      <category>testing</category>
    </item>
    <item>
      <title>The bytes that are not yours</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Thu, 29 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-bytes-that-are-not-yours-59gj</link>
      <guid>https://dev.to/crtdrops/the-bytes-that-are-not-yours-59gj</guid>
      <description>&lt;p&gt;A tracker sends a 60-byte report: position, time, speed, inputs. If it reports every 30 seconds while moving and every 5 minutes while parked, that is 1,368 reports a day. Multiplied out, 82,080 bytes a day: about 2.5 MB a month. That is the math people use to pick each device's data plan.&lt;/p&gt;

&lt;p&gt;The real math never comes out that way. With the same report, it can come to three and a half times that, or almost eighty times that.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The figures in the examples are illustrative: they come from the calculator with an assumed tracker sending 60 bytes per report, not from a bill or a measured network.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What travels with each report
&lt;/h2&gt;

&lt;p&gt;The network does not bill your 60 bytes: it bills the IP packets that carry them. With TCP and the connection already open, one report is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your 60 bytes, with 40 bytes of IP and TCP header;&lt;/li&gt;
&lt;li&gt;the server's reply confirming it got them: a few bytes of its own and another 40 of header;&lt;/li&gt;
&lt;li&gt;the ACK the device sends to confirm that reply: 40 more bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is 209 bytes per report, counting keepalives, reconnections and 2 % retransmissions. A month comes to 8.6 MB instead of 2.5. Your data is 28.7 % of what you pay for; the headers alone, 38.2 %, and the ACKs, 19.1 %.&lt;/p&gt;

&lt;p&gt;And that is still the cheap version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening a connection costs more than the report
&lt;/h2&gt;

&lt;p&gt;Some firmware opens a connection for each report, sends it and closes it. With TCP and no TLS, opening and closing costs 320 bytes: more than the report with everything above.&lt;/p&gt;

&lt;p&gt;With TLS it is another scale. Before sending anything, the device and the server shake hands: they exchange certificates and keys, and that usually weighs 3 to 6 KB, mostly because of the size of the certificates (with a two-certificate RSA chain; with ECDSA it can be less than half, and if the device presents its own certificate, more). With a 4 KB handshake on every report:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How it connects&lt;/th&gt;
&lt;th&gt;Each report costs&lt;/th&gt;
&lt;th&gt;MB a month&lt;/th&gt;
&lt;th&gt;Your data&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TCP, connection kept open&lt;/td&gt;
&lt;td&gt;209 bytes&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;td&gt;28.7 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS, connection kept open&lt;/td&gt;
&lt;td&gt;286 bytes&lt;/td&gt;
&lt;td&gt;11.7&lt;/td&gt;
&lt;td&gt;21.0 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS, new connection per report&lt;/td&gt;
&lt;td&gt;4,653 bytes&lt;/td&gt;
&lt;td&gt;191.0&lt;/td&gt;
&lt;td&gt;1.3 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same device, same report, encrypted both ways: with a new connection per report it uses 16 times what it uses with the connection kept open. The TLS handshakes and records are 87.2 % of what gets paid for. In a fleet of 500 devices, that is 95.5 GB a month against 5.9 GB.&lt;/p&gt;

&lt;p&gt;TLS is not the problem: you have to encrypt. The problem is paying for the full handshake 1,368 times a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shrinking the report barely helps
&lt;/h2&gt;

&lt;p&gt;The natural reaction to a high bill is to compress the report. With a new connection per report, going from 60 to 30 bytes saves less than 1 %: from 191.0 to 189.7 MB a month. With TCP and the connection kept open, the same cut saves about 15 %, from 8.6 to 7.3 MB. Before taking bytes out of the report, it is worth knowing how many bytes are the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does change the math
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep the connection open.&lt;/strong&gt; With TLS, from 191.0 to 11.7 MB a month. It costs keepalives, because the carrier's network usually cuts connections that go a while without traffic, and it costs a new handshake every time the connection drops and reopens: with 24 reconnections a day instead of 6, it goes up to 14.1 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resume the TLS session.&lt;/strong&gt; If the device and the server resume the previous session instead of shaking hands from scratch, the handshake drops to a few hundred bytes. With a new connection per report and a resumed 400-byte handshake, a report costs 981 bytes: 40.3 MB a month instead of 191.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle reports.&lt;/strong&gt; With the connection open and 10 reports per send, each report costs 137 bytes and the month, 5.6 MB. The cost is that, while moving, a report can arrive up to four and a half minutes late, and while parked, up to 45 minutes, and that keepalives weigh more, because the gaps between sends are longer: they become 27.4 % of the total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UDP.&lt;/strong&gt; No connection, no TCP ACKs: 122 bytes per report and 5.0 MB a month. But those 5.0 MB are unencrypted, and UDP does not guarantee the report arrives: confirmation and resend have to go into your protocol. Encrypted with DTLS, each datagram adds about 37 bytes, and the handshake comes on top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is free. Each one trades bytes for something else: latency, complexity, guarantees. What does not make sense is choosing without seeing the math.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the math does not see
&lt;/h2&gt;

&lt;p&gt;The calculator adds up IP bytes with option-free headers, and assumes the clean case. A real network has more: TCP timestamps add 12 bytes to every packet, IPv6 adds 20, a server that is slow to reply sends its ACK separately, noticing that a connection is already dead costs retransmissions, application heartbeats weigh more than a TCP keepalive, DNS lookups cost their share, and the commands and updates the server sends are not counted. And there is something that can weigh more than all of that: some carriers round each data session up to a minimum. With many reconnections, what is billed can end up well above what was transmitted.&lt;/p&gt;

&lt;p&gt;The way to know is to measure: a test device with a traffic capture on the server side, compared with what the carrier reports for that line over the same period.&lt;/p&gt;

&lt;p&gt;To run the math with your own numbers, there is the &lt;a href="https://crtdrops.mx/plan-datos/" rel="noopener noreferrer"&gt;cellular data calculator&lt;/a&gt;: it runs in the browser and shows where every byte goes, over UDP, TCP or TLS, with the connection kept open or new on every send.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;What ate the most of your fleet's data plan: the reports, the connections or something you only found on the bill?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>networking</category>
      <category>embedded</category>
      <category>security</category>
    </item>
    <item>
      <title>Ghosts in the frame: my 2001 robot-vision tricks against two local vision models</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Wed, 28 Oct 2026 19:34:09 +0000</pubDate>
      <link>https://dev.to/crtdrops/ghosts-in-the-frame-my-2001-robot-vision-tricks-against-two-local-vision-models-1nnm</link>
      <guid>https://dev.to/crtdrops/ghosts-in-the-frame-my-2001-robot-vision-tricks-against-two-local-vision-models-1nnm</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In 2001 I was finishing electronics engineering at the Universidad Iberoamericana in Mexico City. The final project was ambitious: a mobile robot that steered by what it saw. There were three of us, so we split it in three, and the vision part was mine: a DSP and one of the first USB cameras, 640×480 in grayscale, one frame per second, slow to respond and with every frame to process. It worked within the limits of its paradigm: no inference at all. The background had to be uniform, we even had to know its color in advance, and the object had to be less than 50 cm away. Window filters, a threshold and a centroid did the rest.&lt;/p&gt;

&lt;p&gt;The images of that era came with ghosts, though not from that camera. Analog TV had them literally: the signal bounces off a building, arrives a few microseconds late, and a faint copy of everything sits to the right of the picture. At the time I also worked on an echo canceler that measured that ghost on a reference signal and took it out.&lt;/p&gt;

&lt;p&gt;This week's theme is the ghost in the machine, in the days when Mexico remembers its dead. So I brought that eye back, wrote it again, and haunted the robot's scene with the ghosts of analog TV. Then I put it in front of two vision models from today, running locally on a mini PC. Same haunted frames for all of them. Which one does the ghost fool?&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Three eyes, and a scene to haunt them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 2001 eye,&lt;/strong&gt; written now in C99 with nothing but the standard library: 3×3 window filters (mean, median, Laplacian edges and a minimum-distance edge test), a threshold, the centroid of whatever passes it, and the echo canceler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two eyes from today:&lt;/strong&gt; Qwen2.5-VL 3B and Gemma 3 4B, open-weight vision models running in Ollama on an Intel N100 mini PC with 8 GB of RAM. CPU only, no internet, no API key, one model loaded at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A haunted scene generator.&lt;/strong&gt; A bright disk 44 px wide moves over a uniform background, 640×480 grayscale, the scene the 2001 eye needed. Each frame gets one ghost at one of four strengths: the multipath echo of analog TV (a copy 40 px to the right, at 20, 35, 50 or 70 %), impulse noise, Gaussian noise or interference bars. Plus a clean case and a combined one: eighteen cases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every frame is synthetic and comes from a fixed seed, so the true position of the disk is known exactly. Nobody has to judge by eye who got it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The whole run, frame by frame, is at &lt;a href="https://crtdrops.mx/fantasmas/" rel="noopener noreferrer"&gt;crtdrops.mx/fantasmas&lt;/a&gt; (English and Spanish on the same page). Pick a ghost, a strength and a frame, and the page draws where each eye said the disk was, next to where it really was, with what each model answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/israel0negrete-crtdrops/ghosts-in-the-frame" rel="noopener noreferrer"&gt;github.com/israel0negrete-crtdrops/ghosts-in-the-frame&lt;/a&gt;, MIT. It is the code exactly as it ran, with the logs and every answer each eye gave (the models' answers saved up to their first 300 characters; four of Qwen's are cut there). Names and comments are in Spanish, as they ran; the prompts are in English. &lt;code&gt;comparar.py&lt;/code&gt; rebuilds the comparison from those answers, byte for byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The scene&lt;/strong&gt; (&lt;code&gt;escena.py&lt;/code&gt;, standard library only) writes each frame as a PGM and the true positions as JSON. The top 8 rows carry a reference signal: black with a short white pulse, the way analog TV carried test and ghost-cancel reference signals in its vertical blanking interval. The echo distorts that pulse like everything else, and that is the whole point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The echo canceler&lt;/strong&gt; looks for the copy of the pulse in the reference rows. Where the copy starts gives the delay of the ghost; how bright it is gives its strength. Then it removes the ghost line by line with a recursive filter, &lt;code&gt;y[x] = g[x] − a·(y[x−d] − black)&lt;/code&gt;, the exact inverse of the ideal single bounce this scene simulates. A real ghost has more bounces and is never this clean. The canceler does not guess from the picture: it measures the ghost on a signal whose shape it already knows. That is an advantage the models don't have. They see the same rows, and their prompt only tells them to ignore them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The filters and the centroid.&lt;/strong&gt; A 3×3 window slides over the frame. The median throws away isolated outliers, which is what impulse noise is. Then every pixel above a fixed threshold counts, and the centroid of those pixels is the answer. Four filters plus none, with and without the canceler: ten variants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The models&lt;/strong&gt; get each frame as a PNG through Ollama's API, with temperature 0, seed 42 and a 4,096-token context. Gemma is asked for the center of the disk as &lt;code&gt;{"x", "y"}&lt;/code&gt;. Qwen is asked for its bounding box, which is how it was trained to locate objects. The run script only works from 7:00 to 21:30, so it never competes with the lab's night shift, and it can be stopped and resumed.&lt;/p&gt;

&lt;p&gt;I built it with Claude Code as a pair: the scene and the techniques come from my proposal, Claude wrote most of the code, and we ran it together on the lab machines. The 2001 eye also ran on a Raspberry Pi 5 and gave exactly the same points as on the N100.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out
&lt;/h2&gt;

&lt;p&gt;Same 72 frames (18 cases, four frames each), on the N100:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Eye&lt;/th&gt;
&lt;th&gt;Median error&lt;/th&gt;
&lt;th&gt;Time per frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2001 · median filter + echo canceler&lt;/td&gt;
&lt;td&gt;0.1 px (on the hardest ghost, Gaussian σ 50: 7.8 px)&lt;/td&gt;
&lt;td&gt;37.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5-VL 3B&lt;/td&gt;
&lt;td&gt;1.3 px on the 37 frames where it gave a full box&lt;/td&gt;
&lt;td&gt;146 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 4B&lt;/td&gt;
&lt;td&gt;177.8 px&lt;/td&gt;
&lt;td&gt;104 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Qwen did not always answer the same way. On 37 frames it gave a full box, and the center is measured. On 32 it gave only the top-left corner, 2.8 px off, with a width and height it made up (from 10 to 500 px for a disk about 71 px wide in its scale), so there is no center to measure. On 3 it kept nesting the same structure until the 4,096-token context ran out, with no coordinates at all.&lt;/p&gt;

&lt;p&gt;The 2001 eye is closer, and about 3,900 times faster than Qwen. Both on the same CPU, with one difference worth saying: the C timing covers the processing of a frame already in memory, while the models' time includes the HTTP call, decoding and resizing the image, and writing the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ghosts did
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Qwen sees the disk, but answers in another scale.&lt;/strong&gt; The first numbers said its median error was 366 px, which looked like blindness. It wasn't. Ollama reported 1,122 input tokens on every frame, and that number gave it away. Take out the roughly 86 tokens of the prompt and 1,036 are left: 37×28 tokens, one per 28×28 px block, which means Ollama had enlarged the 640×480 frame to 1,036×784 before Qwen saw it. Qwen answered in that image, even though the prompt said 640×480. Rescaled, the four clean frames land within 1.4 px. I didn't tune the scale until it fit; it comes from the token count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The echo fools Qwen first.&lt;/strong&gt; On 10 of the 11 echo frames where Qwen gave a full box, the box takes in the disk and its ghost together: about 86 px wide, roughly the 44 px disk plus the 40 px delay, so its center moves about 20 px to the right. That happens from the weakest echo, 20 %, even though its prompt says to ignore any fainter ghost copy of the disk. The 2001 eye without its canceler withstands the 20 and 35 % echoes, but only because a ghost that faint stays under its fixed threshold; at 50 and 70 % the ghost passes it and drags the centroid the same 20 px. With the canceler it stays within a pixel on all four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma answered (320, 240) on all 72 frames,&lt;/strong&gt; the clean ones too: the exact center of the image. With this prompt it does not locate the disk. I tried one prompt only; another might do better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every 2001 trick works.&lt;/strong&gt; With the Laplacian or the minimum-distance edge test and a fixed threshold, the eye loses the disk as soon as there is any noise at all: Gaussian σ 10, or 1 % impulse noise. I picked the median + canceler variant after seeing the results, as the one with the lowest worst case. All ten variants are in the repo, the bad ones included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does open innovation matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I could look inside.&lt;/strong&gt; The 366 px error was not a blind model, it was a scale. I found it because Ollama tells me exactly how many tokens each image became, and how the model and Ollama's open code turn an image into tokens is public. A hosted API would give me the token count too, but not the exact resize, and not a model that stays the same while I figure it out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could swap models the same day.&lt;/strong&gt; When Gemma kept answering the center, I pulled Qwen2.5-VL and ran the same frames through the same script. Same machine, same seed, one flag changed, which also switches to Qwen's bounding-box prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can pin it.&lt;/strong&gt; &lt;code&gt;gemma3:4b&lt;/code&gt; (id &lt;code&gt;68ceab7e468a&lt;/code&gt;) and &lt;code&gt;qwen2.5vl:3b&lt;/code&gt; (id &lt;code&gt;fb90415cde1e&lt;/code&gt;), both Q4_K_M, on Ollama 0.40.6. Anyone can pull them, check that the ids match and run the same frames again. A hosted model can change between one run and the next; these only change when I change them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing to run&lt;/strong&gt; on a mini PC that was already on, and nothing leaves the lab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be precise about "open": Gemma's weights are under the Gemma Terms of Use, and Qwen2.5-VL 3B's under the Qwen Research License, which is non-commercial, even though Ollama's listing shows Apache 2.0. Neither is an OSI license. What matters here is that the weights are on my disk and I can run them, inspect them and pin them. Ollama is MIT, and so is my code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 2001 still knew
&lt;/h2&gt;

&lt;p&gt;None of this says the models are bad. A 3B model that has never seen my scene, with a short prompt as its only instruction, finds a 44 px disk to within a pixel or two when it gives a full box and there is no echo. The 2001 eye knows exactly one thing, and it was made for that.&lt;/p&gt;

&lt;p&gt;But that is the lesson I keep from this: when you know the shape of your ghost, you can measure it and take it out, and a few lines of arithmetic beat a model that has to guess. I felt proud seeing it. Mathematics runs the universe, and even with AI it will still be the base of everything. Nostalgia, yes. But technology moves forward by improving on what it already learned, and nothing is impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize categories
&lt;/h2&gt;

&lt;p&gt;Best Use of Gemma: Gemma 3 4B runs locally in Ollama as one of the two vision models in the comparison. This post reports what it did, including that with this prompt it answered the center of the image every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;What technique from twenty-five years ago do you still trust more than a model?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>computervision</category>
    </item>
    <item>
      <title>The average that never happened</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-average-that-never-happened-4bb0</link>
      <guid>https://dev.to/crtdrops/the-average-that-never-happened-4bb0</guid>
      <description>&lt;p&gt;We ran YOLO26n for seventy-two minutes non-stop on a Raspberry Pi 5: half an hour with one process using all four cores, ten minutes of cooling and half an hour with four processes at once, one core each. Put the 25,898 inferences together and take the average, and you get 347 ms. No inference took that long. None even took between 268 and 397 ms: the average falls in an empty gap. It describes something that never happened.&lt;/p&gt;

&lt;p&gt;For a long time we got used to asking how long a system takes to respond. This note is about why that question, answered with a single number, is not enough.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The figures in this note are not illustrative: they come from the sustained runs we published under CC BY 4.0, and anyone can recompute them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  It matters for different reasons
&lt;/h2&gt;

&lt;p&gt;In the systems we have built, latency matters in different ways. In an electronic top-up there is a financial transaction and a customer waiting: a late response creates uncertainty, because you do not always know right away whether the operation was rejected, went through, or simply did not respond in time. In GPS telemetry, the information has to arrive in time for alerts to have operational value. In video analytics, if processing takes longer than the stream of images allows, frames pile up or detection opportunities are lost.&lt;/p&gt;

&lt;p&gt;Historically, a good part of the monitoring has focused on finding slow transactions, errors and timeouts. Today I find reporting only an average insufficient. I want the whole distribution, especially the p95, the p99 and the extremes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Each condition separately
&lt;/h2&gt;

&lt;p&gt;The p50, or median, is the time below which half the measurements fall; the p95, 95 %; the p99, 99 %. In milliseconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device and condition&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;Maximum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pi 5, one process&lt;/td&gt;
&lt;td&gt;130.2&lt;/td&gt;
&lt;td&gt;129.7&lt;/td&gt;
&lt;td&gt;135.2&lt;/td&gt;
&lt;td&gt;137.7&lt;/td&gt;
&lt;td&gt;267.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pi 5, four processes&lt;/td&gt;
&lt;td&gt;595.6&lt;/td&gt;
&lt;td&gt;595.4&lt;/td&gt;
&lt;td&gt;608.3&lt;/td&gt;
&lt;td&gt;615.4&lt;/td&gt;
&lt;td&gt;702.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pi 5, mixed&lt;/td&gt;
&lt;td&gt;347.4&lt;/td&gt;
&lt;td&gt;134.7&lt;/td&gt;
&lt;td&gt;605.3&lt;/td&gt;
&lt;td&gt;611.8&lt;/td&gt;
&lt;td&gt;702.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N100, one process&lt;/td&gt;
&lt;td&gt;56.7&lt;/td&gt;
&lt;td&gt;56.2&lt;/td&gt;
&lt;td&gt;57.5&lt;/td&gt;
&lt;td&gt;63.6&lt;/td&gt;
&lt;td&gt;159.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N100, four processes&lt;/td&gt;
&lt;td&gt;218.3&lt;/td&gt;
&lt;td&gt;217.9&lt;/td&gt;
&lt;td&gt;224.5&lt;/td&gt;
&lt;td&gt;231.4&lt;/td&gt;
&lt;td&gt;297.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N100, mixed&lt;/td&gt;
&lt;td&gt;139.0&lt;/td&gt;
&lt;td&gt;209.1&lt;/td&gt;
&lt;td&gt;223.1&lt;/td&gt;
&lt;td&gt;227.3&lt;/td&gt;
&lt;td&gt;297.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With one process, the Pi's median stayed between 128 and 131 ms; with four, between 593 and 597. Both conditions keep the whole processor busy; what changes is how it is shared. Those are two different behaviors, and each one, on its own, fairly predictable. Mixed, the average falls between them, where there is nothing. The mixed median does not describe the four-process phase either: it depends on which of the two phases had more inferences.&lt;/p&gt;

&lt;p&gt;On the N100 it goes the other way: the mixed average, 139 ms, falls below the median, 209. The average can mislead in either direction.&lt;/p&gt;

&lt;p&gt;I have no documented production incident in which a good average hid a failure. There is no need to make one up: here it is, measured, how an aggregate metric erases the difference between two operating conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tail within one condition
&lt;/h2&gt;

&lt;p&gt;Separating the phases does not finish the job. With one process, the Pi had a p99 of 137.7 ms, but its maximum was 267.9: almost double. Only 51 of 13,813 inferences went past 150 ms. On the N100, the p99 was 63.6 ms and the maximum, 159.5.&lt;/p&gt;

&lt;p&gt;Then there is the first inference. In Round 1 we discarded twenty warm-up inferences; in the sustained test we recorded every inference from the first one, without discarding any. It shows: the first one took 148 ms on the Pi, against about 125 for the ones after it, and 155 on the N100, against about 56. Keeping it makes it possible to see the startup and how it differs from the steady state. But they are two different methodologies, and they should not be presented as if they were the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency is not capacity
&lt;/h2&gt;

&lt;p&gt;With four processes, each inference on the Pi took 4.6 times longer, because it runs on one core instead of four. The device, however, went from 7.7 to 6.7 inferences per second, because four were running at once. On the N100, each inference took 3.9 times longer and capacity rose from 17.6 to 18.3 per second.&lt;/p&gt;

&lt;p&gt;If you only look at latency, four processes look like a disaster. If you only look at capacity, they cost little: 13 % less on the Pi and 4 % more on the N100. They are two different questions, and an architecture needs both answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a short test does not see
&lt;/h2&gt;

&lt;p&gt;The third dimension is time. On the Pi with one process, the median of each five-minute window rose from 128 to 131 ms while each window's peak temperature went from 77 to 85 °C. On the N100 with four processes, from 213 to 222 ms, with peaks from 92 to 102 °C. What the processor's clock did meanwhile is in &lt;a href="https://crtdrops.mx/notas/the-throttling-you-do-not-see.html" rel="noopener noreferrer"&gt;the throttling note&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A short test gets it wrong in both directions. The first 100 inferences of the Pi with four processes give a median of 577 ms; the whole run, 595. The first 100 with one process give a p99 of 148.5 ms, against 137.7 for the whole run: there were several slow inferences at startup, the first one among them, and with 100 measurements the p99 rests on one or two.&lt;/p&gt;

&lt;p&gt;An architecture should not be judged only by its best result, but by the behavior it can sustain.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much to trust a p99?
&lt;/h2&gt;

&lt;p&gt;With 100 measurements, a single one is left above the p99: the p99 depends on one measurement. Assuming nothing about the shape of the distribution, and with 95 % confidence, with 100 samples you can put a floor under the p99, but not a ceiling; that takes at least 368. With the 13,813 from the Pi with one process, the p99 would lie between 137.4 and 137.9 ms, if the measurements were independent and from the same conditions. They are not entirely: the p99 of each five-minute window went from 135.0 to 138.3, more than that whole interval.&lt;/p&gt;

&lt;p&gt;But sample size is no substitute for representativeness. Ten thousand measurements on a cold device, with no other load, over five minutes, say a lot about those conditions and little about the ones in operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeout that improves the p99
&lt;/h2&gt;

&lt;p&gt;There is a seemingly small decision that changes the interpretation: what to do with the operations that go past the timeout. If an operation ends by timeout, its real latency is unknown. It cannot be given the timeout's value to compute percentiles: it is a censored observation, and it counts as a failure.&lt;/p&gt;

&lt;p&gt;Take the Pi with four processes, a 600 ms target and a 605 ms timeout. Of 12,085 inferences, 1,407 would not finish: 11.6 %. Counting them as failures, the real compliance with the target is 69.6 %, and the p95 and the p99 have no value: all that is known is that they are over 605 ms. If they are discarded, the sample keeps only the ones that finished: compliance rises to 78.7 % and the p99 comes out at 604.7 ms, below the timeout by construction.&lt;/p&gt;

&lt;p&gt;With the phases mixed and a 300 ms timeout it is worse. Here 46.7 % do not finish, and discarding them, the p99 lands at 137.7 ms and compliance with a 150 ms target at 99.6 %. Counting them, that compliance is 53.1 %. Discarding the timeouts makes the four-process phase disappear from the measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inference is not the system
&lt;/h2&gt;

&lt;p&gt;All of the above is inference latency, with random input. A video system has capture, preprocessing, inference, postprocessing and generating the event. A camera at 30 frames per second produces one every 33.3 ms. That does not force you to run inference on every frame, but it does force you to define what rate the application needs: to detect a vehicle or a person, a selection of frames may be enough, depending on how fast things move and what you are looking for. What you cannot do is let frames pile up without limit.&lt;/p&gt;

&lt;p&gt;These tests measure inference under controlled conditions. They are not evidence of plates or frames lost in production: that takes measuring the whole chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Separate the conditions.&lt;/strong&gt; Do not mix the device's measurements under one condition with the same device's under another, such as one process and four processes, or inference latency with the whole system's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask for the distribution, not a number.&lt;/strong&gt; From a vendor I would ask, at a minimum, for p50, p95, p99, maximum observed and the share of operations that exceed the target time. And for the conditions of the measurement: concurrent requests, test duration, hardware and operation volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the target with a percentile and a window.&lt;/strong&gt; In a contract, a latency target tied to a percentile and an evaluation window, with separate requirements for availability and error rate. For example, that 99 % of the operations meet a certain time, counting errors and timeouts as failures so they do not disappear from the measurement. The threshold depends on the application: I would not use the same one for a top-up, an administrative API and real-time detection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not confuse the timeout with the response time.&lt;/strong&gt; In top-up integrations we have configured timeouts of up to 60 seconds. That says nothing about how long a top-up normally takes, and it is not necessarily a contractual commitment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure three things at once.&lt;/strong&gt; Latency: how long each operation takes and how its times are distributed. Capacity: how many operations the system completes per second under different loads. Stability: how both evolve after operating continuously, with temperature, processor frequency and throttling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep every measurement.&lt;/strong&gt; In edge AI we keep every inference and the device's telemetry. That makes it possible to rebuild a run's behavior and recompute any percentile, instead of depending on a summary.&lt;/p&gt;

&lt;p&gt;To see it with this data or yours, there is the &lt;a href="https://crtdrops.mx/latencia/" rel="noopener noreferrer"&gt;latency tool&lt;/a&gt;: it opens the 90,604 inferences phase by phase, tells you how much to trust each percentile and compares counting and discarding the timeouts. It runs in the browser, and if you open your CSV, it is sent nowhere.&lt;/p&gt;

&lt;p&gt;For a long time we got used to asking how long a system takes to respond. Today I think it matters more to ask how long it takes under different conditions, how predictable that time is and for how long it can sustain it. Because an acceptable average latency does not necessarily mean we have a system able to respond in time when we really need it.&lt;/p&gt;

&lt;p&gt;The complete data, every inference with its time, is at &lt;a href="https://crtdrops.mx/datos/" rel="noopener noreferrer"&gt;crtdrops.mx/datos&lt;/a&gt;, under CC BY 4.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;When you agree on a latency target with a vendor or a client, what is it written against: the average, a percentile, or the timeout? And in your measurements, what happens to the operations that time out: are they counted or do they just disappear? If you want to try it with your own measurements, the tool is at &lt;a href="https://crtdrops.mx/latencia/" rel="noopener noreferrer"&gt;crtdrops.mx/latencia&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>machinelearning</category>
      <category>raspberrypi</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Night shift: a local Gemma writes my 7 a.m. lab report, and I measured where it makes things up</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Sat, 24 Oct 2026 19:45:36 +0000</pubDate>
      <link>https://dev.to/crtdrops/night-shift-a-local-gemma-writes-my-7-am-lab-report-and-i-measured-where-it-makes-things-up-4hlf</link>
      <guid>https://dev.to/crtdrops/night-shift-a-local-gemma-writes-my-7-am-lab-report-and-i-measured-where-it-makes-things-up-4hlf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What happened in the lab overnight? Most mornings the answer is nothing, and finding that out still means opening logs. Some mornings it is something, and logs are a slow way to find out.&lt;/p&gt;

&lt;p&gt;So I built a night shift. Two small machines send a heartbeat all night, fixed rules go over the night at 6:30 a.m., and before 7 each machine leaves a short report written by Gemma 3, running on the machine itself, with no internet and no API key. The lazy part: nobody has to read logs in the morning. The careful part: I did not hand the model that job until I had measured where it gets things wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;The lab is an Intel N100 mini PC and a Raspberry Pi 5 on the same network.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A heartbeat.&lt;/strong&gt; Every 10 seconds each machine sends a small record: a sequence number, when it was sent, when its process started, CPU load, available memory, temperature, and how many heartbeats it failed to send. Each machine also keeps its own copy on disk, in case the link is what fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A collector on the N100&lt;/strong&gt; that stores what arrives, with the arrival time. Both machines send to it, the N100 to itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A detector&lt;/strong&gt; that runs once, at 6:30 a.m., over the whole night (10 p.m. to 6:30 a.m.) with fixed rules. It writes a JSON file with every incident: start, end, duration, when it was detected, whether it recovered on its own, and the evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A writer.&lt;/strong&gt; Gemma 3 4B in Ollama reads only that JSON and writes the morning report in Markdown. It does not decide whether something failed. That was decided before it woke up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each machine writes its own report from the same JSON: the N100 at 6:30, the Pi at 6:40. That lets me compare an x86 mini PC and an ARM board doing the same job with the same model. One known gap: everything goes through the N100, so if the N100 is down, there is no report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The four evaluated nights, one by one, are at &lt;a href="https://crtdrops.mx/turno/" rel="noopener noreferrer"&gt;crtdrops.mx/turno&lt;/a&gt;: the injected faults, what the rules detected and both reports of each night, with what the checker flagged and what reading them turned up.&lt;/p&gt;

&lt;p&gt;This is part of the Pi's report after a night with eight injected faults, exactly as Gemma wrote it (the prompt is in Spanish; lines wrapped, two of the eight incidents shown):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Turno de noche: 2026-10-21 22:00
**Salud general:** mala. El 99.9% de los latidos recibidos provienen del
equipo N100, mientras que el 87.2% provienen del Raspberry Pi 5.

## Incidentes
*   **N100:** servicio reiniciado, 22:39:51 - 22:40:30, 39 s, detectado
    22:40:21, no confirmado
    Hechos observados: Ningún latido recibido de 22:39:51 a 22:40:30 (39 s;
    se esperaba uno cada 10 s). El latido siguiente viene de un proceso
    nuevo, arrancado a las 22:40:30. El vigilante del equipo registró la
    caída del proceso a las 22:40:00 (código -15). / Posibles explicaciones:
    Fallo en el sistema operativo. / Causa confirmada: no confirmada
*   **Raspberry Pi 5:** el equipo no envió, 23:29:51 - 23:35:01, 310 s,
    detectado 23:30:21, no confirmado
    Hechos observados: Ningún latido recibido de 23:29:51 a 23:35:01 (310 s;
    se esperaba uno cada 10 s). El mismo proceso antes y después; faltan 30
    latidos en la secuencia. El propio equipo registró 30 latidos como no
    enviados en ese lapso. / Posibles explicaciones: Problema de red. /
    Causa confirmada: no confirmada
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Translation.&lt;/strong&gt; Night shift: 2026-10-21 22:00. Overall health: bad. 99.9 % of the heartbeats received come from the N100, while 87.2 % come from the Raspberry Pi 5.&lt;/p&gt;

&lt;p&gt;N100: service restarted, 22:39:51–22:40:30, 39 s, detected 22:40:21, not confirmed. Observed facts: no heartbeat received from 22:39:51 to 22:40:30 (39 s; one expected every 10 s). The next heartbeat comes from a new process, started at 22:40:30. The machine's watchdog logged the process going down at 22:40:00 (code -15). Possible explanations: operating system failure. Confirmed cause: not confirmed.&lt;/p&gt;

&lt;p&gt;Raspberry Pi 5: the machine did not send, 23:29:51–23:35:01, 310 s, detected 23:30:21, not confirmed. Observed facts: no heartbeat received from 23:29:51 to 23:35:01 (310 s; one expected every 10 s). The same process before and after; 30 heartbeats missing from the sequence. The machine itself logged 30 heartbeats as unsent in that span. Possible explanations: network problem. Confirmed cause: not confirmed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The observed facts are right. Several things around them are wrong, and finding them is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Everything that ran is in &lt;a href="https://github.com/israel0negrete-crtdrops/night-shift" rel="noopener noreferrer"&gt;github.com/israel0negrete-crtdrops/night-shift&lt;/a&gt;, MIT licensed: heartbeat, watchdog, relay, collector, detector, writer, fault injector, fault plans and checker, all standard-library Python plus Ollama. It is the code exactly as it ran on these nights, not a cleaned-up version: the files on both machines match the repository byte for byte. It also has the raw heartbeats of the two fault nights; replaying the detector on them gives exactly the same JSON, and the writer can be replayed on any of the nights.&lt;/p&gt;

&lt;p&gt;I built it with Claude Code as a pair: I decided what to measure, what the lab could and could not be put through, and what the report had to say; Claude wrote most of the code and ran it on the machines with me.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the model does not read the logs
&lt;/h3&gt;

&lt;p&gt;The first version was the obvious one: give Gemma the last 40 warnings from the N100's journal and ask for two lines. I gave the same 40 to both machines.&lt;/p&gt;

&lt;p&gt;Both took about 2 minutes, wrote a paragraph instead of two lines, and described the desktop's background noise (GNOME, PipeWire, a keyring) as “system instability”; one recommended investigating it. On a desktop machine most warnings are not failures, and the model has no way to know which ones are.&lt;/p&gt;

&lt;p&gt;So the job got split. Rules decide what happened; the model only puts it into words. The detector is under 200 lines. And to know whether the rules were right, I needed nights where I knew exactly what had failed and when.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three fields that make a gap readable
&lt;/h3&gt;

&lt;p&gt;The sequence number, the process start time and the machine's own count of what it failed to send. A new start time after a gap means the process restarted. The same process with missing sequence numbers means heartbeats went missing. And if the machine itself counted them as unsent, they never left; if it did not, they left and never arrived. Without those three fields, all of these look like the same hole in the chart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nights with known faults
&lt;/h3&gt;

&lt;p&gt;Two quiet nights first, with no faults, as a baseline. Then two nights with eight controlled faults each, injected by a script at fixed times and logged with their real start and end; the second night moved each fault to the other machine and to another hour:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fault&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;What the detector should say&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interruption&lt;/td&gt;
&lt;td&gt;kills the heartbeat process; a watchdog restarts it 30 s later&lt;/td&gt;
&lt;td&gt;service restarted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Silence&lt;/td&gt;
&lt;td&gt;the process stays alive but stops sending&lt;/td&gt;
&lt;td&gt;the machine did not send&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cut&lt;/td&gt;
&lt;td&gt;the relay drops that machine's heartbeats&lt;/td&gt;
&lt;td&gt;heartbeats lost on the way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;the relay delays them 3–5 s&lt;/td&gt;
&lt;td&gt;latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;four busy processes on four cores&lt;/td&gt;
&lt;td&gt;CPU saturation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;holds memory until 15 % is left&lt;/td&gt;
&lt;td&gt;low memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pause&lt;/td&gt;
&lt;td&gt;stops the process until after the window closes&lt;/td&gt;
&lt;td&gt;no heartbeat, unresolved&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most faults touch only the experimental flow. CPU and memory put bounded load on the machine itself: the CPU fault stops at a temperature limit, and the memory fault never leaves less than 600 MB. No reboots, no changes to the machines' real network, no production service stopped.&lt;/p&gt;

&lt;h3&gt;
  
  
  A checker for the writer
&lt;/h3&gt;

&lt;p&gt;A script compares each report with the JSON it was given. It looks for incidents left out, times and figures that are not in the JSON, causes stated as confirmed, adjectives like “high temperature” with no incident behind them, incidents invented on a quiet night, and a missing or invented comparison with the previous night. The real fault log grades the detector, not the report.&lt;/p&gt;

&lt;p&gt;The checker runs on my laptop, not in the lab, and it misses things. Most of what is wrong in the demo above I found by reading the reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: detection
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Night&lt;/th&gt;
&lt;th&gt;Faults injected&lt;/th&gt;
&lt;th&gt;Detected&lt;/th&gt;
&lt;th&gt;False alarms&lt;/th&gt;
&lt;th&gt;Detection delay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quiet 1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quiet 2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Faults, night 1&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;21–30 s; CPU 111 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Faults, night 2&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;20–30 s; CPU 112 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the quiet nights, 3,060 of 3,060 heartbeats arrived from each machine. With faults, the rules found all sixteen, each with the right type. The last fault of each night, a pause that runs past 6:30, came out as unresolved, which is correct.&lt;/p&gt;

&lt;p&gt;The second night also had an incident nobody planned. The CPU fault pushed the N100 to 84 °C, over the 80 °C rule, for 50 heartbeats, and the detector reported high temperature. My checker counted it as a false alarm because it was not in the plan, but the temperature really did cross the line: it is a side effect of the fault, and the rule was right to catch it.&lt;/p&gt;

&lt;p&gt;The detector runs once, at 6:30, so “detected” is when the rule would have fired, reconstructed from the data, not a live alert. The CPU fault shows up late because the one-minute load average needs about a minute and a half to pass 3.0 (75 % of four cores), plus 20 seconds for the rule's three heartbeats in a row.&lt;/p&gt;

&lt;p&gt;The detection is boring, and it should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: what Gemma wrote
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the first quiet night, both machines invented incidents.&lt;/strong&gt; The JSON said zero incidents. Each report listed one per machine anyway, built from that machine's peak temperature (73 °C on the N100, 50.1 °C on the Pi, no rule fired), gave them a duration of “00:30” that appears nowhere in the data, and recommended checking the temperature or the ventilation. A quiet night is exactly when a summarizer is tempted to find something to say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the second quiet night,&lt;/strong&gt; the Pi correctly wrote that there were no incidents, but left out the comparison with the previous night, which was in the JSON. The N100 included the comparison but listed normal readings under “Incidents”. And both put the start of the night in the title (2026-10-20 22:00) instead of the morning date the template asks for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the first fault night, it got the structure and most of the numbers right.&lt;/strong&gt; Neither machine left out an incident or stated a cause as confirmed, and the checker found no time or figure that is not in the JSON. But the checker only looks for numbers that appear nowhere in the JSON, and that is how the worst error got through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Pi invented a fact with a real number.&lt;/strong&gt; For the last incident, the pause still open at 6:30, it wrote that “the machine itself logged 30 heartbeats as unsent in that span”. That sentence belongs to the silence incident six hours earlier. The JSON gave this incident two facts, and the Pi dropped one of them: that no heartbeat had arrived by the end of the window. It could not even be true, because the pause kills the heartbeat process, so nothing was counting. The 30 passed the checker because 30 appears elsewhere in the JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The health line misreads the numbers.&lt;/strong&gt; 99.9 % and 87.2 % are the share of each machine's own heartbeats that arrived, not where the heartbeats came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The possible explanations contradict the evidence.&lt;/strong&gt; The Pi logged its 30 heartbeats as unsent, so they never reached the network; the real cause was the silence fault, and “network problem” is ruled out by the facts two lines above. The N100's process was stopped with a normal termination signal (code -15), which is not an operating system failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Not confirmed” where the template asks whether it recovered on its own.&lt;/strong&gt; The JSON said it did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checker did catch one: the Pi skipped the comparison again, and this time so did the N100, writing that there was no previous night to compare while the JSON carried its figures. The rest is smaller: the N100 reduced each incident to one generic line (“The N100 restarted the service.”) and dropped the evidence, the Pi wrote “null” for the end of the incident still open at 6:30, and both titles carried the start of the night again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the second fault night, both machines swapped two incidents between machines.&lt;/strong&gt; The 22:29 interruption was the Pi's, and both reports put it under the N100; the 23:20 latency was the N100's, and both put it under the Pi. Same mistake on x86 and on ARM, and the checker cannot see it, because both machine names are in the JSON. The N100's health line also gave the Pi 87.2 %, which was the previous night's figure, copied from the comparison block; that night the Pi had 98.5 %. It wrote 23:40:22 as the detection time of an incident detected at 00:40:22, which the checker did catch. And both skipped the comparison once more: three nights in a row for the Pi.&lt;/p&gt;

&lt;p&gt;Same model, same JSON, same prompt, temperature 0 and seed 42 on both machines, and the text still came out different on each. At temperature 0 the seed barely matters; what differs is the arithmetic. The x86 and ARM builds round slightly differently, and one different token changes everything after it. I did not test repeat runs on the same machine, so I cannot say that pinning the hardware is enough, only that pinning the model is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  N100 or Pi 5?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;N100&lt;/th&gt;
&lt;th&gt;Raspberry Pi 5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Report with 8 incidents&lt;/td&gt;
&lt;td&gt;294.8 s&lt;/td&gt;
&lt;td&gt;468.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading the prompt (2,565 tokens)&lt;/td&gt;
&lt;td&gt;20.7 tokens/s&lt;/td&gt;
&lt;td&gt;26.3 tokens/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writing the report&lt;/td&gt;
&lt;td&gt;5.7 tokens/s (934 tokens)&lt;/td&gt;
&lt;td&gt;3.3 tokens/s (1,199 tokens)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Report on a quiet night&lt;/td&gt;
&lt;td&gt;86–128 s&lt;/td&gt;
&lt;td&gt;76–101 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the second fault night it was 303.4 s on the N100 and 520.9 s on the Pi. The Pi reads faster and writes slower, and with faults it also writes more, because it copies the evidence that the N100 drops. Three tokens per second sounds slow, and at 6:40 a.m. it does not matter: nobody is waiting, and both reports are ready well before 7. Slow and steady is enough for a night shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does open innovation matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The logs never leave the lab.&lt;/strong&gt; Ollama listens on 127.0.0.1. Nothing about my machines goes to anyone else's server, and the night shift keeps working if the internet does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing per night.&lt;/strong&gt; No tokens to pay for, no key to rotate. It runs on hardware that was already on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can pin it.&lt;/strong&gt; The model is &lt;code&gt;gemma3:4b&lt;/code&gt;, id &lt;code&gt;68ceab7e468a&lt;/code&gt;, Q4_K_M, with temperature 0, seed 42 and an 8,192-token context, on Ollama 0.40.6. A hosted model can change under you between one night and the next; this one only changes when I change it. That matters when the point is to measure it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can run the same model on x86 and on ARM&lt;/strong&gt; and see the difference, which is how I found that the same settings do not give the same text on two processors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be precise about “open”: Gemma's weights are open under the Gemma Terms of Use, not an OSI license; Ollama is MIT. What matters here is that both run entirely on my machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lazy payoff
&lt;/h2&gt;

&lt;p&gt;Nobody has to open logs in the morning: read the health line, and if it is not “good”, the incidents under it. The health word comes from the detector's JSON; Gemma copied it correctly every night, but that is Gemma copying, not a guarantee, so the next change is to have the detector write that line itself.&lt;/p&gt;

&lt;p&gt;What I do not do is trust the report alone. The rules decide, I run the checker over what the model wrote, and its explanations are treated as guesses, because the fault night showed they can contradict the facts right above them. It has run for five nights, not months, and that is what five nights showed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize categories
&lt;/h2&gt;

&lt;p&gt;Best Use of Gemma: Gemma 3 4B runs locally in Ollama on both machines and writes every morning report, and this post measures where it gets things right and wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Where would you let a local model write without anyone reviewing it, and where would you not?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>The right position at the wrong time</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-right-position-at-the-wrong-time-gp0</link>
      <guid>https://dev.to/crtdrops/the-right-position-at-the-wrong-time-gp0</guid>
      <description>&lt;p&gt;A truck loses its signal at ten in the morning and gets it back at noon. As soon as it is back, it sends two things at once: the positions it stored with no signal and the ones it is generating right then. All of them are correct. If the platform does not tell them apart, it mixes history with the present.&lt;/p&gt;

&lt;p&gt;A position can be technically correct and still lead to the wrong operational decision if it is read outside its time context. That is what this note is about.&lt;/p&gt;

&lt;p&gt;We have worked for years with GPS tracking, telemetry and embedded devices, over GSM/GPRS cellular networks and over LoRaWAN. On the move, you cannot assume the connection will always be there. What matters is not only how long a device is offline, but how it behaves meanwhile and what happens when it comes back.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The figures in the examples are illustrative: they come from a simulator with an assumed working day of a fleet, not from measurements of our platforms.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  It is not a device connected to a database
&lt;/h2&gt;

&lt;p&gt;The simplest way to think about telemetry is a device that sends data and a database that stores it. With that picture, store-and-forward looks like a device problem: give it enough memory and have it send what it stored.&lt;/p&gt;

&lt;p&gt;Our evolution has been to stop thinking about it that way. Today we see it as a distributed system where each stage has its own job: receive, acknowledge, transport, store, interpret and present. The problem does not end when the device manages to resend its data. What the platform does with it matters too.&lt;/p&gt;

&lt;p&gt;On the device side, there is no general rule either. How it stores, how much it holds and what it does when it fills up depend on the manufacturer, the firmware and the model. Some store and send later; others have memory limits or settings of their own. It has to be checked device by device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three different times for the same data point
&lt;/h2&gt;

&lt;p&gt;Every position has three moments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When it was generated&lt;/strong&gt;, on the device.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When the server received it.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it became available for operations&lt;/strong&gt;, already processed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a day without failures, the three nearly coincide. After a disconnection, they drift apart: a position generated at 11:30 can arrive at 12:00 and, with the server's queue full, become available more than four minutes later.&lt;/p&gt;

&lt;p&gt;The device clock deserves attention too. With GPS, the device can take the time from the positioning system, but not every device keeps reliable time under every condition. And if the event time is not reliable, nothing built on top of it is either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acknowledging is not processing
&lt;/h2&gt;

&lt;p&gt;When a device gets its signal back, there is no guarantee that everything arrives, in order and without duplicates. That depends on the protocol and on how it was implemented. In TCP integrations with protocols such as JT808, you need to understand their sequence, acknowledgment and retransmission mechanisms.&lt;/p&gt;

&lt;p&gt;And there is a critical difference: the gateway acknowledging a message does not mean the whole platform has already processed and stored it. If an acknowledgment is lost, the device sends the same thing again, and the server has to accept the duplicate without creating duplicate records or events.&lt;/p&gt;

&lt;p&gt;Recognizing a duplicate is not trivial either. Matching device and time is not enough. Neither is the sequence number: in JT808 it is two bytes and wraps back to zero, so it repeats. The right approach is idempotency per protocol and event type: a unique message identifier when there is one and it is reliable, and composite keys when needed.&lt;/p&gt;

&lt;p&gt;Some protocols help from the moment of reception. In JT808, the batch position upload, message 0x0704, says whether the positions are normal ones or were stored in an area with no coverage. With that flag, the gateway can recognize history as soon as it arrives. The flag applies to the whole batch, and some devices send stored positions as normal ones, without it. Whether each device model and firmware version uses it is something to verify, not assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accepting connections is not processing them
&lt;/h2&gt;

&lt;p&gt;Receiving one position from each device at regular intervals is not the same as receiving, all at once, the backlog of many devices while their current positions keep coming in. Accepting TCP connections is one capacity; processing and storing everything received is another.&lt;/p&gt;

&lt;p&gt;In the simulator, a fleet of 50 devices sends one position a minute for eight hours. At 10:00 the network goes down for 41 of them, for two hours. Each device holds up to 100 positions and, when full, drops the oldest. Once back, it sends the present without waiting and resends what it stored at two records a second, and 2 % of the acknowledgments are lost. The platform processes 10 messages a second, twelve times the fleet's normal load.&lt;/p&gt;

&lt;p&gt;The signal returns at 12:00, and in less than a minute the server's queue reaches 3,634 messages; the wait, 6.1 minutes. In that queue, 63 current positions waited more than five minutes behind the fleet's accumulated history. Along the way, 843 positions were lost because memory filled up, 436 duplicates arrived from lost acknowledgments, and 1,728 arrivals were out of order.&lt;/p&gt;

&lt;p&gt;More memory does not fix everything. On the same day, with 500 positions per device nothing is lost, but the avalanche grows: the queue reaches 4,406 messages and 139 current positions wait more than five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  History or present
&lt;/h2&gt;

&lt;p&gt;The most delicate part is not only losing data. It is receiving valid data outside its time context.&lt;/p&gt;

&lt;p&gt;If the platform treats everything that arrives as if it were happening now, history mixes with the present, and that hits routes, reports, geofences, alarms and indicators. With a geofence it is very clear: if a vehicle entered a restricted zone three hours ago and the position is only now reaching the server, an alert saying it has just entered makes no sense.&lt;/p&gt;

&lt;p&gt;In the simulator there are 194 real geofence entries and exits during the day. A platform that treats everything as "now" raises 240 alerts: 140 on time, 54 about things that happened a while ago raised as if they were happening at that moment, and 46 false ones, from history and duplicates mixing with the present and making the device seem to go in and out. It also stores the 436 duplicates. Those false alerts depend on how the device resends: if everything goes out in order, they drop to 30; with no lost acknowledgments, to 16; with both, to zero.&lt;/p&gt;

&lt;p&gt;A platform that separates history and present drops the duplicates, stores the history in its place and only alerts on the present. It raises 156 alerts: 140 on time and 16 late ones that say they are late. No false ones, though that is by construction: the simulator only carries the failures we put in it. And the route, rebuilt with each position's time, recovers 186 of the 194 entries and exits correctly timed; the 8 missing ones had their records wiped by the device's full memory, and no platform can fix that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treat disconnection as a normal condition, not an exception.&lt;/strong&gt; The design starts by assuming the connection will fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Split the responsibilities into layers:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reception:&lt;/strong&gt; accept, validate and identify the device's messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messaging:&lt;/strong&gt; decouple communication from processing with queues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence:&lt;/strong&gt; store idempotently, without duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal processing:&lt;/strong&gt; tell historical from current.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business rules:&lt;/strong&gt; evaluate geofences, alarms and operating conditions while respecting the time context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presentation:&lt;/strong&gt; show both the last known state and the history correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Store the event time and the reception time separately.&lt;/strong&gt; A historical position keeps its original time, if it is valid, and reception is recorded apart. That way the route can be rebuilt without altering the chronology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tell a current condition from a historical event detected later.&lt;/strong&gt; History rebuilds the route; the present triggers alerts. If something happened with no signal and is discovered on reconnection, it is recorded as what it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure where it gets stuck.&lt;/strong&gt; Separating reception, queue and processing makes it possible to see at which point in the flow a delay happens, instead of guessing.&lt;/p&gt;

&lt;p&gt;In our current architectures we work with reception gateways, RabbitMQ, processing services, databases and event distribution over Socket.IO. That decouples reception from processing, although on its own it does not guarantee that a device kept everything during the disconnection.&lt;/p&gt;

&lt;p&gt;To try it with another fleet, another network outage, another memory size or another processing rate, there is the &lt;a href="https://crtdrops.mx/reenvio/" rel="noopener noreferrer"&gt;store-and-forward simulator&lt;/a&gt;: it runs in the browser and compares both platforms with the three times of each data point.&lt;/p&gt;

&lt;p&gt;Receiving information is not the same as receiving timely information, and processing a message correctly does not guarantee we are reading it in the right time context. A good telemetry platform does not just survive devices going offline: it recovers their history without confusing it with the present.&lt;/p&gt;

&lt;p&gt;Why a device unloads thousands of frames after a night without coverage, and what that does to a backend that takes the arrival time as the event time, is part of my guide &lt;strong&gt;Virtual Fleets&lt;/strong&gt;. Chapter 4, on the receiving side, is free as a PDF:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://israelnegretelepe.gumroad.com/l/a-read-is-not-a-frame" rel="noopener noreferrer"&gt;israelnegretelepe.gumroad.com/l/a-read-is-not-a-frame&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual Fleets&lt;/strong&gt; — &lt;a href="https://israelnegretelepe.gumroad.com/l/virtual-fleets" rel="noopener noreferrer"&gt;israelnegretelepe.gumroad.com/l/virtual-fleets&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;When a device reconnects, how does your platform tell history from the present: by a flag the device sends, by the event time, or not at all? And does a late geofence entry still raise an alert as if it were happening now? If you want to try your own fleet and outage, the simulator is at &lt;a href="https://crtdrops.mx/reenvio/" rel="noopener noreferrer"&gt;crtdrops.mx/reenvio&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>architecture</category>
      <category>backend</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>The geofence computed on the fly</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Tue, 20 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-geofence-computed-on-the-fly-k2f</link>
      <guid>https://dev.to/crtdrops/the-geofence-computed-on-the-fly-k2f</guid>
      <description>&lt;p&gt;A truck spends the week between the yard, a customer and an underground lot. The report says that twelve times, with the engine off and nobody touching it, it left the yard and came back in.&lt;/p&gt;

&lt;p&gt;For years we used geofences for almost everything: yard entries and exits, no-go zones, customer arrivals, routes. Circles and polygons; polygons were a more interesting challenge than a plain circle. And for years we fought the same failures: alerts that matched nothing, arrivals that never showed up, times that did not add up.&lt;/p&gt;

&lt;p&gt;This note is about those failures, about the fixes almost everyone applies, and about a design mistake that stays hidden until someone changes a value: computing the geofence on the fly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The figures in the examples are illustrative: they come from a simulator with a typical week of a truck, not from measurements of those fleets.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  In or out looks like an easy question
&lt;/h2&gt;

&lt;p&gt;For a circle, one distance is enough: if the position is closer to the center than the radius, it is inside. For a polygon, the classic test draws a line from the position off to one side and counts how many times it crosses the edge: odd, inside; even, outside. An L-shaped yard, a zone that follows an avenue, a customer with two gates: all of that is a polygon.&lt;/p&gt;

&lt;p&gt;The geometry is the easy part. The hard part is that the question is put to a GPS position, and the position does not always say where the truck is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What failed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Parked, the position jumps farther than the geofence.&lt;/strong&gt; With the truck standing still, accuracy sometimes falls apart and a position lands a hundred or two hundred meters away. The simulator's yard is eighty meters across. To the geofence that is an exit, and the next good position is an entry. In the history, the truck seems to cover large distances in a few minutes without having moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Positions that arrive late.&lt;/strong&gt; A 7:10 position that arrives at 7:30, after the 7:20 ones, puts the truck back where it no longer was. If the geofence is evaluated in arrival order, that is one more entry and one more exit that never happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The device stops transmitting right before it arrives.&lt;/strong&gt; In an underground parking lot the signal is lost on the ramp. The last position stays on the street and the next one comes in once the truck has already left. The entry never shows up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the interval makes all of it worse.&lt;/strong&gt; With the ignition on, our devices reported every 3 minutes; off, every 120. At 40 km/h, the truck covers 2 km in 3 minutes. A 400 m no-go zone is crossed in 36 seconds, so only one crossing in five leaves a position inside.&lt;/p&gt;

&lt;p&gt;In the simulator, one week of that truck: 205 reports, 70 real entries and exits. Deciding with the raw point, position by position, gives 45 false alerts: 31 with the truck parked in the yard, and 14 from positions that arrived late. It sees 3 of the 14 zone crossings and none of the 7 entries into the underground lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bit of all three
&lt;/h2&gt;

&lt;p&gt;The fixes we applied, and that almost everyone running a fleet applies, were a bit of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A margin to exit.&lt;/strong&gt; The geofence is entered at the edge, but to exit the truck has to get a certain distance away. That removes the dithering of a truck parked by the fence. Against a two-hundred-meter jump it does nothing: with a 30 m margin, the 45 false alerts drop to 39.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waiting for several reports in a row.&lt;/strong&gt; A lone jump confirms nothing. With two positions in a row, false alerts drop to 13. But with the ignition off each position comes two hours after the previous one: the seven returns to the yard are reported two to six hours late, and seven of those 13 are real arrivals at the wrong time. And a single late position in between breaks the streak: it sees 3 of the 7 customer arrivals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using the ignition or the speed.&lt;/strong&gt; With the engine off, the truck does not move. That removes almost all the yard ones; 21 remain, mostly from late positions and from the stretch when the engine warms up with the truck parked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one helps, and each one is paid for somewhere else. I would sum up what we learned like this: any intervention or change of values eventually causes false positives or altered information.&lt;/p&gt;

&lt;h2&gt;
  
  
  On the fly
&lt;/h2&gt;

&lt;p&gt;And the worst part was none of those. On that platform, a geofence entry or exit was not stored: it was computed in the web client, on the fly, every time someone opened the map or the report.&lt;/p&gt;

&lt;p&gt;As long as nobody touches anything, that seems to work. The problem arrives with the first complaint. Someone enlarges the yard so the alerts stop, or raises the margin, or changes the customer's polygon. From that moment on, last week's report is recomputed with the new geofence and says something else.&lt;/p&gt;

&lt;p&gt;In the simulator, enlarging the yard by 100 m brings the false alerts from 45 down to 31: it does not silence them, because the late positions and the longest jumps are still there. And in that same week's report, computed on the fly with the new yard, 23 alerts change: 16 disappear, 3 move to a different time and 4 appear that were not there before. If someone asked on Tuesday about a yard alert, the one they were shown may no longer be there, or may be at another time.&lt;/p&gt;

&lt;p&gt;A history that changes when the configuration changes is not a history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Store the event, once.&lt;/strong&gt; The entry or exit is computed when the position arrives and stored, with the version of the geofence and that day's values. If the yard grows tomorrow, it grows from tomorrow on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stamp it with the position's time.&lt;/strong&gt; Not the time it arrived. A late position is slotted into its place in the trip, and the alert carries the time it happened, even if it is sent later. If that position changes an event already stored, the event is corrected and the record says it was corrected and why: a late data point explains the change; a new configuration does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop the position, not the report.&lt;/strong&gt; A position flagged as bad by its own quality data is discarded. But the report still says the device spoke and, if it is the ignition-off report, when the truck stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm with more than one signal.&lt;/strong&gt; Two positions in a row, or the ignition-off report, or a position whose GPS speed is above 15 km/h. On most devices speed is measured separately, from the Doppler effect, and barely jumps with the position: a truck doing 40 km/h is not jumping. With the ignition off, a jump does not take the truck anywhere; if the GPS says it is moving, that is a different alert, the tow alert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the segment, not just the point.&lt;/strong&gt; If the straight line between two positions goes through the no-go zone, the truck most likely crossed it, even if no report fell inside. The time is interpolated along the segment, and the crossing is stored as inferred. With real streets, the line is checked against the street layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three states, not two.&lt;/strong&gt; Inside, outside and unknown. When the device goes quiet near a place known to have no signal, the entry is inferred, and stored marked as inferred. Whoever reads the report knows what was seen and what was deduced.&lt;/p&gt;

&lt;p&gt;With as much of that as the simulator can model, in the same week: no false alerts, all 70 entries and exits, 36 of them inferred and marked as such (the zone crossings and the underground lot), and the time 1.1 minutes off on average. Not because it is perfect: the simulator only carries the failures I put in it, and the street has others. But each of those rules answers something that fails.&lt;/p&gt;

&lt;p&gt;To try it with another week, with more bad or late positions, another margin or a bigger yard, there is the &lt;a href="https://crtdrops.mx/geocerca/" rel="noopener noreferrer"&gt;geofence simulator&lt;/a&gt;: it runs in the browser and decides in or out five ways.&lt;/p&gt;

&lt;p&gt;Geofences did not fail because of the geometry. They failed because a lone position was asked something only the truck's history can answer, and because that answer was not stored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Where does your platform decide in or out: when the position arrives, or every time someone opens the map? And if someone changed a geofence today, would last month's report change with it? If you want to try your own margin and rules on a full week, the simulator is at &lt;a href="https://crtdrops.mx/geocerca/" rel="noopener noreferrer"&gt;crtdrops.mx/geocerca&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>gps</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>The top-up nobody knows went through</title>
      <dc:creator>Israel Negrete Lepe</dc:creator>
      <pubDate>Fri, 16 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/crtdrops/the-top-up-nobody-knows-went-through-o3j</link>
      <guid>https://dev.to/crtdrops/the-top-up-nobody-knows-went-through-o3j</guid>
      <description>&lt;p&gt;A customer asks for a prepaid airtime top-up at a corner store. The system debits the store's balance and sends the request to the carrier. The carrier does not answer.&lt;/p&gt;

&lt;p&gt;Did the top-up reach the phone? At that moment nobody knows. And with the carrier, nobody can ask: the answer arrives tomorrow, in the reconciliation.&lt;/p&gt;

&lt;p&gt;For years I worked on an electronic top-up platform. Requests came in from terminals, apps and points of sale, and reached the carrier after two, three and up to four hops, each with its own protocol and its own timeout. Everything happened: double top-ups, charges with no top-up. This note is about why, and about the state almost no system designs in from the start.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The figures in the examples are illustrative: calculated to show the mechanism. They are not that platform's numbers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  There are three answers, not two
&lt;/h2&gt;

&lt;p&gt;A system that moves money is usually thought of with two outcomes: the operation went through, or it did not. On a network there is a third: &lt;strong&gt;unknown&lt;/strong&gt;. The request went out and the answer never came back. The request may have been lost, and the top-up does not exist. Or the answer may have been lost, and the top-up is already on the phone.&lt;/p&gt;

&lt;p&gt;From this side of the wire, the two cases look identical.&lt;/p&gt;

&lt;p&gt;Every hop is one more place where an answer can get lost. If each one loses one in two thousand, with a hundred thousand top-ups a day, one hop leaves 50 in doubt; four hops, 200. Every day. It is not a bug you fix once: it is a probability you manage forever.&lt;/p&gt;

&lt;p&gt;And the hardest case is at the end of the chain: &lt;strong&gt;the carrier does not answer, or its answer gets lost, but the internal system has already debited&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everyone decides what to do with "unknown"
&lt;/h2&gt;

&lt;p&gt;When the screen hangs, somebody decides, and every decision is a bet with someone else's money:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cashier presses the button again.&lt;/strong&gt; If the first one did go through, the customer gets two top-ups and the store pays for two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The terminal retries on its own.&lt;/strong&gt; Same thing, without anyone noticing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The platform retries toward the carrier.&lt;/strong&gt; Same thing, one hop deeper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The platform marks the operation as failed and refunds the store.&lt;/strong&gt; If the carrier did apply it, the platform gave away a top-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The platform marks it as good.&lt;/strong&gt; If the carrier did not apply it, the customer paid and got nothing, and is going to complain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them is right. They are all bets as long as nobody knows what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  An ID that travels end to end
&lt;/h2&gt;

&lt;p&gt;The first defense is for every top-up to have a unique identifier from the moment it is born, in the terminal or the app, and for that same identifier to travel through every hop. We called it the &lt;strong&gt;externalID&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;With it, a retry stops being a new top-up. If a request arrives with an externalID that was already processed, the system does not run it again: it answers with the result of the first one. The cashier can press ten times and the store pays once.&lt;/p&gt;

&lt;p&gt;And it makes it possible to ask. &lt;strong&gt;Backwards, from the platform toward the points of sale, you could&lt;/strong&gt;: the terminal could ask what happened to its externalID instead of sending another top-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you cannot ask
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;With the carriers, you could not.&lt;/strong&gt; There was no status query. The only thing was batch reconciliation: a file every 24 hours with what the carrier says it applied, against what the platform says it sent.&lt;/p&gt;

&lt;p&gt;That means a top-up in doubt stays in doubt until the end of the day, or until the next day. Meanwhile, the store's balance has already been debited, the customer may be complaining, and the platform has nothing to tell them.&lt;/p&gt;

&lt;p&gt;And reconciliation, when it comes, is torture: cross-matching files, finding the differences, deciding each one, and sometimes resolving them by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not reverse
&lt;/h2&gt;

&lt;p&gt;The way out that looks obvious is the reversal: when in doubt, cancel the operation with the carrier and return the money. In this business &lt;strong&gt;reversals are not desirable: they kill the business&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The margin on a top-up is very small. Every reversal costs work, time and arguments with the carrier, and eats the profit of many sales. And if you reverse something that did go through, the loss is total.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do from the start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Design "unknown" as a real state.&lt;/strong&gt; In the database, on the cashier's screen and in the reports: &lt;em&gt;in progress&lt;/em&gt;, not &lt;em&gt;failed&lt;/em&gt; or &lt;em&gt;successful&lt;/em&gt;. A screen that says "in progress, do not repeat it" is worth more than any later validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The externalID from the origin, mandatory, and honored at every hop.&lt;/strong&gt; A request with no identifier does not get in. A repeated identifier returns the stored result and never runs again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No human retries blindly.&lt;/strong&gt; The retry button checks the status first. If there is no answer, it shows that the operation is in progress; it does not send another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staggered timeouts.&lt;/strong&gt; Each hop has to wait longer than the next one. If the terminal gives up before the platform does, the terminal retries while the first request is still alive inside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic reconciliation from day one.&lt;/strong&gt; Not as a spreadsheet someone puts together once there are problems, but as part of the system: the carrier's file comes in, gets matched on its own, and a person only gets the differences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure the doubts.&lt;/strong&gt; How many top-ups stay in progress, per carrier, per hour and per hop. When that number goes up, something broke, and it is better to know today than in tomorrow's reconciliation.&lt;/p&gt;

&lt;p&gt;To see what each way of deciding costs with your own numbers (top-ups a day, hops, timeouts), there is the &lt;a href="https://crtdrops.mx/recarga/" rel="noopener noreferrer"&gt;top-up simulator&lt;/a&gt;: it runs in the browser and counts the doubled, given-away and charged-without-top-up operations of each policy, and what waits for reconciliation with an externalID.&lt;/p&gt;

&lt;p&gt;None of these measures eliminates "unknown". What they do is stop it from being a bet: the money sits in a state everyone can see, nobody duplicates it out of desperation, and reconciliation only has to resolve what truly could not be known. Mitigating it was a lot of programming and infrastructure work. There was no shortcut.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How does your system handle an operation stuck in "unknown": do retries carry an idempotency key end to end, or does someone decide by hand? If you want to try your own hops and timeouts, the simulator is at &lt;a href="https://crtdrops.mx/recarga/" rel="noopener noreferrer"&gt;crtdrops.mx/recarga&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Israel Negrete Lepe is an electronics engineer. Twenty-five years making different systems work together under real conditions: from the sensor, the vehicle or the transaction to the backend, the data, security and operations. Today, increasingly, edge AI. I take on projects by the hour or by deliverable: &lt;a href="mailto:israel0negrete@gmail.com"&gt;israel0negrete@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I send a short email when a new note or lab result comes out: &lt;a href="https://israelnegretelepe.gumroad.com/subscribe" rel="noopener noreferrer"&gt;subscribe here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>backend</category>
      <category>architecture</category>
      <category>distributedsystems</category>
    </item>
  </channel>
</rss>
