<?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: Tim Collinson</title>
    <description>The latest articles on DEV Community by Tim Collinson (@trcollinson).</description>
    <link>https://dev.to/trcollinson</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%2F4155385%2F90299d5e-bc9b-44c7-a4ee-7996622eaa35.jpg</url>
      <title>DEV Community: Tim Collinson</title>
      <link>https://dev.to/trcollinson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/trcollinson"/>
    <language>en</language>
    <item>
      <title>Athena read my timestamps as UTC. It didn’t tell me.</title>
      <dc:creator>Tim Collinson</dc:creator>
      <pubDate>Tue, 13 Oct 2026 21:19:36 +0000</pubDate>
      <link>https://dev.to/trcollinson/athena-read-my-timestamps-as-utc-it-didnt-tell-me-oah</link>
      <guid>https://dev.to/trcollinson/athena-read-my-timestamps-as-utc-it-didnt-tell-me-oah</guid>
      <description>&lt;p&gt;Nobody gets an error. Somebody gets blamed.&lt;/p&gt;

&lt;p&gt;I built a &lt;a href="https://dev.to/trcollinson/your-erp-says-the-job-was-profitable-your-machines-disagree-57hm"&gt;fake metal fab plant&lt;/a&gt; to find this kind of bug before a real customer does. Last week it caught one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The MES in the fake plant writes timestamps the way real MES systems do: local time, no time zone. &lt;code&gt;14:30&lt;/code&gt; means 2:30 PM at the plant. The plant is in Mountain Time.&lt;/p&gt;

&lt;p&gt;I wrote the obvious SQL to convert plant-local timestamps to UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;scan_on&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DuckDB, running locally, read that as: “this value is in Denver time; give me the UTC moment that corresponds to it.” Correct for what I meant.&lt;/p&gt;

&lt;p&gt;Athena read that as: “this value is UTC; give me the Denver moment that corresponds to it.” Also correct, but for a different interpretation.&lt;/p&gt;

&lt;p&gt;Same line of SQL. Two engines. Two answers. Six hours apart. No warning on either side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trino’s behavior is the SQL standard: &lt;code&gt;AT TIME ZONE&lt;/code&gt; says what zone to convert to. If the value doesn’t already carry a time zone, it’s assumed to be UTC. DuckDB is more lenient. In this case, DuckDB’s leniency is what let me write sloppy SQL on my laptop and have it quietly change meaning in production.&lt;/p&gt;

&lt;p&gt;Trino isn’t the one being weird. DuckDB is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Say explicitly what time zone the value is IN, then convert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;with_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas on the Athena side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;with_timezone&lt;/code&gt; returns a &lt;code&gt;timestamp with time zone&lt;/code&gt;. Athena views can’t store zoned timestamps, so cast back to plain &lt;code&gt;timestamp&lt;/code&gt; at the view boundary.&lt;/li&gt;
&lt;li&gt;If you want the same SQL to run in both engines, define a DuckDB macro that aliases &lt;code&gt;with_timezone(ts, zone)&lt;/code&gt; to DuckDB’s native &lt;code&gt;timezone(zone, ts)&lt;/code&gt;. Then one line of SQL works everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a plant, six hours is a whole shift. Get the conversion wrong and second shift’s downtime lands on first shift’s report. Nobody gets an error. Somebody gets blamed.&lt;/p&gt;

&lt;p&gt;Bonus: it’s six hours in summer and seven in winter. Daylight saving time ends in Mountain Time on November 1. I wrote a test specifically for that boundary, and it caught a sneaky off-by-one on fall-back. If you aren’t testing against DST transitions, you have a bug and don’t know it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The SQL, both the broken version in the comment and the fix: &lt;a href="https://github.com/trcollinson/crestline-supply/blob/main/queries/margin_gap.sql" rel="noopener noreferrer"&gt;margin_gap.sql&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The DuckDB macro that makes the SQL portable: &lt;a href="https://github.com/trcollinson/crestline-supply/blob/main/queries/sources.sql" rel="noopener noreferrer"&gt;sources.sql&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The DST transition test: &lt;a href="https://github.com/trcollinson/crestline-supply/blob/main/tests/test_shifts.py" rel="noopener noreferrer"&gt;test_shifts.py&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>dataengineering</category>
      <category>manufacturing</category>
      <category>athena</category>
    </item>
    <item>
      <title>Your ERP says the job was profitable. Your machines disagree.</title>
      <dc:creator>Tim Collinson</dc:creator>
      <pubDate>Thu, 01 Oct 2026 17:29:58 +0000</pubDate>
      <link>https://dev.to/trcollinson/your-erp-says-the-job-was-profitable-your-machines-disagree-57hm</link>
      <guid>https://dev.to/trcollinson/your-erp-says-the-job-was-profitable-your-machines-disagree-57hm</guid>
      <description>&lt;h2&gt;
  
  
  What you'll get out of this
&lt;/h2&gt;

&lt;p&gt;In about 12 minutes, you'll walk away with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why your ERP can hide a money-losing machine&lt;/strong&gt;, in plain terms. Standard costing charges machine time at standard hours, so long setups vanish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A working pattern for joining ERP, MES, and machine data on AWS&lt;/strong&gt;: raw files in S3, cataloged in Glue, queried with Athena, deployed with one CDK stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gotchas that cost us time&lt;/strong&gt;, so they don't cost you any: Athena time zones, JSON SerDe key casing, 16-bit counters, and a &lt;code&gt;cdk diff&lt;/code&gt; that isn't read-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three questions to take to your own plant&lt;/strong&gt; this week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A repo you can deploy in your own AWS account&lt;/strong&gt; for a fraction of a cent, and tear down cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plant leaders: read the story, "Why the ERP can't see it," and the three questions at the end. Builders: jump straight to "The rebuild."&lt;/p&gt;

&lt;h2&gt;
  
  
  The margin report says everything is fine
&lt;/h2&gt;

&lt;p&gt;Let's talk about the monthly margin review. Why would I write about a finance meeting on a technical blog? Great question. Stick with me; there's SQL at the end of this rainbow.&lt;/p&gt;

&lt;p&gt;Picture it. Finance puts up the job cost report, and the bracket and enclosure work is holding its numbers. Everyone nods.&lt;/p&gt;

&lt;p&gt;The plant manager doesn't nod. She knows second shift on the older press brake is a mess. Setups drag, jobs finish late, and the operators on that shift are always fighting the machine. But the report says those jobs made money, and she has no numbers to argue with. Nothing she can put on a slide will beat the ERP.&lt;/p&gt;

&lt;p&gt;If you've worked in a mid-market plant, you've probably sat in that meeting. I have sat in versions of it for years, from pushing plant data into a PI historian at BP to building APIs for manufacturing software. (If you don't know what a PI historian is, count yourself lucky.) The plant manager is usually right. The problem is that proving it means joining three systems that were never designed to talk to each other.&lt;/p&gt;

&lt;p&gt;So we built a plant, gave it that exact problem, and set out to prove it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We simulated 90 days at Crestline Supply, a fictional metal fabricator: 1,252 finished jobs across ERP, MES, and press brake data, each realistically imperfect.&lt;/li&gt;
&lt;li&gt;The ERP overstates margin on one brake's second-shift jobs by 4.4 points against the true figure, about $5,800 a quarter from a single machine on a single shift. Setups there run 2.84 times standard, and the ERP can't see it.&lt;/li&gt;
&lt;li&gt;The ERP is wrong everywhere else too, but in the pessimistic direction. It's only flattering where the real problem lives.&lt;/li&gt;
&lt;li&gt;We rebuilt job cost from all three sources with one SQL file running on Amazon Athena. It lands within 1% of the true cost for 91.9% of jobs.&lt;/li&gt;
&lt;li&gt;Everything is open source and runs in your own AWS account. The Athena spend for the whole project was about two-tenths of a cent. &lt;a href="https://github.com/trcollinson/crestline-supply" rel="noopener noreferrer"&gt;https://github.com/trcollinson/crestline-supply&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Meet Crestline Supply
&lt;/h2&gt;

&lt;p&gt;Crestline Supply doesn't exist. I made it up. That's the point. It's a simulated metal fabrication plant that makes electrical enclosures, mounting brackets, and custom panels for HVAC and industrial customers. It runs two shifts. Parts go through a fiber laser, deburring, two press brakes, hardware insertion, powder coat, and assembly.&lt;/p&gt;

&lt;p&gt;Why simulate instead of using real data? Because real plants never know the truth. When your rebuilt numbers disagree with the ERP, you can't tell who's right. In a simulation, you can.&lt;/p&gt;

&lt;p&gt;The simulator works in two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ground truth.&lt;/strong&gt; A discrete-event model records what actually happened: every setup, every stroke, every scrapped part, every operator's real time. This is the answer key, and it stays hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three imperfect views.&lt;/strong&gt; From that truth, the simulator produces what each business system would actually record, complete with the gaps and distortions real systems have.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The analysis only ever sees the imperfect views. A test in the repo enforces that the query code can't read the answer key. The answer key is used for one thing: grading our work at the end.&lt;/p&gt;

&lt;p&gt;The data shapes follow patterns common across mid-market ERPs and PLCs. If you've worked in these systems, they'll look familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three systems, three partial truths
&lt;/h2&gt;

&lt;p&gt;Every system at Crestline is telling the truth about something. None of them is telling the whole truth.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;What it knows&lt;/th&gt;
&lt;th&gt;What it doesn't know&lt;/th&gt;
&lt;th&gt;How it reports&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ERP&lt;/td&gt;
&lt;td&gt;The job, quantity, price, and standard cost&lt;/td&gt;
&lt;td&gt;How long setup and run actually took&lt;/td&gt;
&lt;td&gt;Labor rounded to 0.1 hr, posted the next day; machine burden at standard hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MES&lt;/td&gt;
&lt;td&gt;Who scanned onto which job, and when&lt;/td&gt;
&lt;td&gt;Setup vs. run; whether the operator was really working&lt;/td&gt;
&lt;td&gt;Nightly CSV files in local time with no time zone; about 3% of scans never closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Press brakes&lt;/td&gt;
&lt;td&gt;When each stroke happened&lt;/td&gt;
&lt;td&gt;Which job it belonged to&lt;/td&gt;
&lt;td&gt;PB-01: JSON on every change. PB-02: a 16-bit counter polled every 30 seconds that wraps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The answer to the plant manager's question lives in the gaps between these three. The machines know &lt;em&gt;when&lt;/em&gt; setup ended. The MES knows &lt;em&gt;who&lt;/em&gt; was on the job and for how long. The ERP knows &lt;em&gt;what it was worth&lt;/em&gt;. Nobody has joined them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ERP can't see it
&lt;/h2&gt;

&lt;p&gt;The ERP isn't broken. It's doing exactly what standard costing is designed to do, and that design hides long setups.&lt;/p&gt;

&lt;p&gt;A job's cost has three main parts: material, labor, and burden. Burden is the cost of running the machine itself: depreciation, power, maintenance, floor space. At Crestline, the ERP's own work center rates put an hour on a press brake at $85 of burden and $38 of labor. The burden is the bigger number.&lt;/p&gt;

&lt;p&gt;Here's the catch. Crestline's ERP, like most, takes &lt;strong&gt;actual labor&lt;/strong&gt; from the MES but applies &lt;strong&gt;burden at standard hours&lt;/strong&gt;. If a setup is supposed to take 20 minutes, the ERP charges 20 minutes of machine time, no matter how long the machine actually sat in setup.&lt;/p&gt;

&lt;p&gt;Walk through one setup on PB-02, second shift, running 36 minutes over standard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The operator stays scanned in the whole time, so the extra 36 minutes of labor (about $23) eventually reaches the ERP.&lt;/li&gt;
&lt;li&gt;The extra 36 minutes of machine time (about $51 of burden) never does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More than two-thirds of the cost of that overrun is invisible to finance. Finance isn't hiding it from you. The ERP is hiding it from finance. Now multiply. Over the quarter, PB-02 ran 179 setups on second shift, averaging 43 minutes over standard. That's about 128 machine-hours and roughly $10,900 of burden the ERP never charged. Even elsewhere in the plant, the ERP only sees about 90% of true burden, from ordinary setup variation and downtime. On PB-02 second shift, it sees 72%. A real problem turns into a healthy-looking margin.&lt;/p&gt;

&lt;p&gt;There's a second effect pulling the other way. Operators stay scanned in through breaks, forgotten scans are only trimmed back to the end of the shift, and nobody scans off during downtime. So everywhere in the plant, the ERP charges about a third more labor than anyone actually worked. Labor is the smaller cost, but over-reporting it still charges each job more than it should, so margins look 2 to 4 points &lt;em&gt;pessimistic&lt;/em&gt;. Keep that in mind; it matters for the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rebuild
&lt;/h2&gt;

&lt;p&gt;The rebuild is one SQL file with six commented views and a final select. It runs on Amazon Athena, directly over the raw files in S3.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F884xcuh0fqrpxmg8v10y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F884xcuh0fqrpxmg8v10y.png" alt="Three source boxes at the top: the ERP (job, price, standard cost; burden at standard hours), the MES (who scanned on and when; local time, no offset), and the press brakes (when each stroke happened; no idea which job). Arrows lead into a box labeled " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each system hands the rebuild a different piece of the story. Four steps in one Athena query join them, and the join surfaces the one place the ERP flatters itself.&lt;/p&gt;

&lt;p&gt;The raw files land in S3 exactly as each system would have produced them, untouched. That matters: when you find a mistake later, you can always reprocess from the original.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Put everything on one clock
&lt;/h3&gt;

&lt;p&gt;The MES writes local wall-clock time with no offset. The ERP and machines use UTC. Before anything can be joined, every timestamp needs to mean the same instant.&lt;/p&gt;

&lt;p&gt;This is where the first surprise showed up. &lt;code&gt;scan_on AT TIME ZONE 'America/Denver'&lt;/code&gt; looks like it says "this is plant time." In Athena, it doesn't. Athena reads a bare timestamp as UTC and converts it, so 2:30 PM comes back six hours early (seven in winter). No error, just a wrong answer. We only caught it because our local test engine read the same line the opposite way.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;with_timezone()&lt;/code&gt;, which states which zone the value is in. Athena views also can't store a zoned timestamp, so each view casts back to plain UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- The MES writes local time with no offset, so the conversion has to say&lt;/span&gt;
&lt;span class="c1"&gt;-- which zone it is in: with_timezone(). Not `at time zone`, which the two&lt;/span&gt;
&lt;span class="c1"&gt;-- engines read differently: DuckDB takes a bare 14:30 as plant time&lt;/span&gt;
&lt;span class="c1"&gt;-- (20:30 UTC), but Athena takes it as UTC and converts, six hours early.&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;job_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;op_seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;work_center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;scan_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan_off&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;scan_off&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Figure out which brake did the work, and when setup ended
&lt;/h3&gt;

&lt;p&gt;The brakes don't know what job they're running. But a job's brake operation has an MES scan window, and a brake produces strokes. The first stroke after an operator scans on marks the end of setup.&lt;/p&gt;

&lt;p&gt;PB-02 makes this harder. PB-02 is old. PB-02 is grumpy. PB-02 gives you a 16-bit stroke counter, polled every 30 seconds, and nothing else. The counter rolls over at 65,535 (ours wrapped once, on August 26 at 2:14 PM), so the strokes since the last poll are the difference mod 65,536:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PB-02's counter is 16 bits wide and rolls over at 65,535, so the&lt;/span&gt;
&lt;span class="c1"&gt;-- strokes since the last poll are the difference mod 65,536.&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="s1"&gt;'PB-02'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;brake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;gw_ts&lt;/span&gt;   &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;counted_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cnt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;gw_ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;strokes&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pb02_counts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That handles rollover, not a counter that resets to zero when someone power-cycles the machine. A reset looks like a wrap and would count phantom strokes. That mess shows up in a later post.&lt;/p&gt;

&lt;p&gt;The 30-second poll caused a subtler bug. PB-02 reports a stroke up to 30 seconds after it happens, so the previous job's last stroke often landed just after the next operator scanned on. Setups looked instant. The fix is to look one poll late at both ends of the scan window.&lt;/p&gt;

&lt;p&gt;Then there's the question in this step's title: which brake? The query counts each brake's strokes during the scan window and picks the brake whose count comes closest to the pieces the operator reported. Ties go to the brake whose last stroke came nearest the operator's scan-off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PB-02 reports strokes up to 30 s late: look 30 s late at both ends.&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;brake_strokes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counted_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30'&lt;/span&gt; &lt;span class="k"&gt;second&lt;/span&gt;
 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counted_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;   &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30'&lt;/span&gt; &lt;span class="k"&gt;second&lt;/span&gt;

&lt;span class="c1"&gt;-- Closest stroke count to the reported pieces wins.&lt;/span&gt;
&lt;span class="c1"&gt;-- On a tie, the brake whose last stroke was nearest scan-off.&lt;/span&gt;
&lt;span class="n"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;op_seq&lt;/span&gt;
    &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strokes&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pieces&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
             &lt;span class="k"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'second'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_stroke_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full version is in &lt;code&gt;queries/margin_gap.sql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Against the answer key, all 935 brake operations were placed, 933 on the right brake and all 935 on the right setup shift. The two misses are two separate exact ties: a pair of 16-piece jobs and a pair of 46-piece jobs, each pair running at the same time, one job on each brake, so the counts matched either way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdh1iw5ns9aqw4nv9djmm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdh1iw5ns9aqw4nv9djmm.png" alt="A timeline of job J100795 (part BRK-1050, press brake PB-02, second shift, August 28, 2026) from 7:30 to 8:45 PM in three lanes. MES lane: operator Tasha Bloom is scanned onto the job from 7:36 to 8:40 PM. PB-02 lane: no strokes for 55 minutes, then 23 strokes between 8:32 and 8:41 PM, with the last stroke reported 28 seconds after scan-off. ERP lane: the ERP charges a 20-minute standard setup starting at scan-on, followed by a 9.2-minute standard run. A shaded band from the end of the ERP's standard setup to the first stroke marks 35 minutes of machine time, about $50 of burden, that the ERP never charges." width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's one real second-shift job from the run. Tasha scanned onto J100795 at 7:36 PM, and PB-02 didn't make its first stroke until 8:32. The ERP charged 20 minutes of setup; the shaded stretch is the machine time it never sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Clean up the labor the way the plant should
&lt;/h3&gt;

&lt;p&gt;About 3% of MES scans are never closed. Someone forgets to scan off, and a supervisor closes the scan at 06:00 the next morning. Posted as-is, a 20-minute operation becomes a 14-hour one. (Nobody bends brackets for 14 hours straight. Nobody.)&lt;/p&gt;

&lt;p&gt;Crestline handles this the way many plants do: a supervisor's labor exception report trims forgotten scans back to the end of the shift. That's better than nothing, but it still charges hours the operator spent on other work.&lt;/p&gt;

&lt;p&gt;The query does one thing the supervisor can't do by hand across thousands of rows: it caps each scan at the same operator's &lt;strong&gt;next scan-on&lt;/strong&gt;. If Tasha scanned onto another job at 3:10, she wasn't still on the first one at 11:00. That single rule is most of the difference between the ERP's labor and the rebuilt labor. And notice who was right all along: the supervisor already knew this data was garbage, and the exception report is a workaround someone built because the systems failed them. With the right join, their daily forensics becomes something the query does continuously, on every job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Recompute cost the way it actually happened
&lt;/h3&gt;

&lt;p&gt;With clean hours and real setup times, cost is straightforward: actual labor hours times the labor rate, actual machine hours times the burden rate, plus material. Then compare the ERP's margin to the rebuilt margin, grouped by brake and by the shift the setup happened on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rebuild found
&lt;/h2&gt;

&lt;p&gt;The ERP is wrong about every group of jobs in the plant. It's only flattering in one place, and that's exactly where the problem is.&lt;/p&gt;

&lt;p&gt;Margin by brake and setup shift, 90 days, 1,252 finished jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Brake and shift&lt;/th&gt;
&lt;th&gt;ERP margin&lt;/th&gt;
&lt;th&gt;Rebuilt margin&lt;/th&gt;
&lt;th&gt;True margin&lt;/th&gt;
&lt;th&gt;Setups vs. standard&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PB-01, 1st shift&lt;/td&gt;
&lt;td&gt;24.3%&lt;/td&gt;
&lt;td&gt;25.5%&lt;/td&gt;
&lt;td&gt;27.1%&lt;/td&gt;
&lt;td&gt;1.06×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PB-01, 2nd shift&lt;/td&gt;
&lt;td&gt;25.9%&lt;/td&gt;
&lt;td&gt;27.8%&lt;/td&gt;
&lt;td&gt;28.6%&lt;/td&gt;
&lt;td&gt;1.07×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PB-02, 1st shift&lt;/td&gt;
&lt;td&gt;23.0%&lt;/td&gt;
&lt;td&gt;23.7%&lt;/td&gt;
&lt;td&gt;25.0%&lt;/td&gt;
&lt;td&gt;1.14×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PB-02, 2nd shift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.84×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No brake operation&lt;/td&gt;
&lt;td&gt;23.6%&lt;/td&gt;
&lt;td&gt;26.7%&lt;/td&gt;
&lt;td&gt;27.7%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"True margin" comes from the hidden answer key. In a real plant you'd have the other three columns and not that one. If you run the query yourself, its gap column shows 5.7 points for PB-02 second shift. That's the ERP against the rebuild, the gap a plant manager would actually see; the 4.4 points in this post is the ERP against the truth.&lt;/p&gt;

&lt;p&gt;Three things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everywhere else, the ERP is pessimistic.&lt;/strong&gt; It understates margin by 2 to 4 points against the truth, because time nobody worked gets charged as job labor. A finance team that learns to discount the ERP a little would be right almost everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On PB-02 second shift, it flips.&lt;/strong&gt; The ERP overstates margin by 4.4 points against the truth. Measured against the plant's usual pessimistic bias, the real gap is bigger than it looks. A finance team that corrected the ERP up by its usual 3 points would put this group at 23.4% against a true 16.0%, a 7.4-point miss. On that group's roughly $131,000 of quarterly revenue, the overstatement is worth about $5,800 a quarter, or roughly $23,000 a year, from one machine on one shift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rebuild errs conservative.&lt;/strong&gt; It lands 1.3 points below the truth on PB-02 second shift, and below the truth in every group by 0.8 to 1.6 points, because the few scans it can't fully clean err high, not low. That's the direction you want a management number to miss in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grumpy PB-02, second shift, exactly where the plant manager knew to look. That's the conversation she needed. Not "second shift feels slow," but "setups on PB-02 second shift run 2.84 times standard, and the ERP can't see it because burden is charged at standard hours."&lt;/p&gt;

&lt;h2&gt;
  
  
  How we know it's right
&lt;/h2&gt;

&lt;p&gt;The rebuild lands within 1% of the true job cost for 91.9% of jobs. The ERP's own "actual" cost does that for 14.9%.&lt;/p&gt;

&lt;p&gt;Because we have the answer key, we can grade the rebuild the way you never can in a real plant. An evaluation script compares every job's rebuilt cost to its true cost:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Rebuilt cost&lt;/th&gt;
&lt;th&gt;ERP "actual" cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jobs within 1% of true cost&lt;/td&gt;
&lt;td&gt;91.9%&lt;/td&gt;
&lt;td&gt;14.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jobs within 5% of true cost&lt;/td&gt;
&lt;td&gt;94.6%&lt;/td&gt;
&lt;td&gt;53.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median error&lt;/td&gt;
&lt;td&gt;0.03%&lt;/td&gt;
&lt;td&gt;4.52%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The more useful part is the misses. All 101 jobs outside 1% are explained: 91 are forgotten scan-offs the query can't fully clean, 9 are operators who stepped away during downtime while still scanned in, and 1 is a machine sitting idle while scanned. None are unexplained.&lt;/p&gt;

&lt;p&gt;One honest note: the first version of the simulator was too clean. The rebuild matched the truth almost perfectly, which told us the plant wasn't messy enough to be believable. Adding forgotten scan-offs made it realistic, and that's also when we had to model the supervisor's exception report, because posting those scans as-is wrecked the ERP's numbers in a way no real plant would tolerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What building it on AWS taught us
&lt;/h2&gt;

&lt;p&gt;The AWS side is deliberately small: an S3 raw bucket, an Athena results bucket, a Glue database, and an Athena workgroup with a 1 GB scan cap, all deployed by one CDK stack. Small didn't mean free of surprises. It never does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Athena reads a bare timestamp as UTC.&lt;/strong&gt; Covered above, and the most likely thing to bite anyone working with plant data. Use &lt;code&gt;with_timezone()&lt;/code&gt; before &lt;code&gt;AT TIME ZONE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The OpenX JSON SerDe lowercases keys, even inside nested objects it returns as text.&lt;/strong&gt; JSON paths are case-sensitive, so fields silently came back null. The fix was reading each PB-01 message as a single text line and using &lt;code&gt;json_extract_scalar&lt;/code&gt;. PLC tag names like &lt;code&gt;N7:12&lt;/code&gt; weren't going to be valid Glue column names anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition projection didn't fit.&lt;/strong&gt; The &lt;code&gt;ingest_date&lt;/code&gt; partition is dated October 1, the extract date, which was still in the future when we built this. A projection range ending at NOW skipped it, so setup runs &lt;code&gt;MSCK REPAIR TABLE&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain &lt;code&gt;cdk diff&lt;/code&gt; isn't read-only.&lt;/strong&gt; It uploads the template and creates a change set. (Yes, really. I was surprised too.) Use &lt;code&gt;cdk diff --method=template&lt;/code&gt; when you want a purely local comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack tags changed.&lt;/strong&gt; With the &lt;code&gt;@aws-cdk/core:explicitStackTags&lt;/code&gt; feature flag, &lt;code&gt;Tags.of()&lt;/code&gt; no longer sets stack tags, so tags are applied both ways on purpose. A Glue database can't be tagged through CloudFormation at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent uploads came almost free.&lt;/strong&gt; For a single-part upload with SSE-S3, the ETag is the content's MD5, so rerunning the upload skips unchanged files. That wouldn't hold with KMS encryption or multipart uploads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is small. Together they're the difference between a demo that works on my machine and one that works in yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The whole thing deploys into your own AWS account in a few minutes, costs well under a dollar, and tears down cleanly. The simulation is deterministic, so you'll get exactly the numbers in this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You'll need:&lt;/strong&gt; uv, Node.js with the CDK CLI at version 2.1143 or later, the AWS CLI, and an AWS profile with a region configured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy and run:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--branch&lt;/span&gt; post-01-margin-gap https://github.com/trcollinson/crestline-supply.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;crestline-supply
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_PROFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;profile&amp;gt;
uv &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk bootstrap &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk deploy&lt;span class="o"&gt;)&lt;/span&gt;
uv run crestline sim backfill &lt;span class="nt"&gt;--days&lt;/span&gt; 90 &lt;span class="nt"&gt;--end&lt;/span&gt; 2026-10-01 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;--upload&lt;/span&gt;
uv run crestline athena setup
uv run crestline query margin-gap &lt;span class="nt"&gt;--engine&lt;/span&gt; athena
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cdk deploy&lt;/code&gt; stops once to ask you to approve the IAM role it creates. Type &lt;code&gt;y&lt;/code&gt; and it carries on.&lt;/p&gt;

&lt;p&gt;Then try the experiment that makes the point. Set &lt;code&gt;enabled: false&lt;/code&gt; under &lt;code&gt;pb02_second_shift_setups&lt;/code&gt; in &lt;code&gt;scenarios.yaml&lt;/code&gt;, rerun the backfill with &lt;code&gt;--upload&lt;/code&gt;, and rerun the query. PB-02 second shift comes back at 25.2% in the ERP and 25.5% rebuilt, with setups at 1.07× standard, right in line with everything else. Bonus detail: with the scenario off, PB-02 second shift handles 219 jobs instead of 174. The slow brake was also doing less work, because each job goes to whichever brake frees up first.&lt;/p&gt;

&lt;p&gt;No AWS account handy? The README has a local option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; the upload is 99 files and 46.6 MB, so storage rounds to zero. Each query run scans about 100 MB, around a twentieth of a cent, so rerun as much as you like. Athena spend for the entire project, including every test query, was about $0.002.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teardown:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk destroy&lt;span class="o"&gt;)&lt;/span&gt;
aws logs describe-log-groups &lt;span class="nt"&gt;--log-group-name-prefix&lt;/span&gt; /aws/lambda/CrestlineFoundation &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'logGroups[].logGroupName'&lt;/span&gt;
aws logs delete-log-group &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &amp;lt;name from above&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cdk destroy&lt;/code&gt; removes the stack and empties the buckets. The two &lt;code&gt;aws logs&lt;/code&gt; commands clean up the log group CDK's auto-delete Lambda leaves behind. If you bootstrapped CDK just for this, also delete the &lt;code&gt;CDKToolkit&lt;/code&gt; CloudFormation stack, then empty and delete the staging bucket it keeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for your plant
&lt;/h2&gt;

&lt;p&gt;You don't need a perfect data platform to find this kind of problem. You need the join, and you need to trust it.&lt;/p&gt;

&lt;p&gt;Three questions worth asking your team this week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do we know actual setup time by machine and shift, or only standard?&lt;/strong&gt; If the answer is "standard," your long setups are hiding in your margins right now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can we connect a machine's output to the job it was running?&lt;/strong&gt; Even a dumb stroke counter and an MES scan window are enough to answer "when did setup actually end?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does finance know burden is applied at standard hours?&lt;/strong&gt; Most people outside cost accounting have never thought about it, and it's the reason the ERP looked healthy here.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answers are "no," you're in good company. That's most mid-market plants I've worked with. The good news is that the data to answer them is usually already sitting in systems you own. Go find it. Do it this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;This post cheated a little. The ERP data arrived as a clean bulk export. In real life, getting data out of a mid-market ERP means tokens that expire mid-extract, paging that shifts under you, and a &lt;code&gt;LastModified&lt;/code&gt; field that doesn't always get updated. That's the next post in the Crestline series, in two weeks.&lt;/p&gt;

&lt;p&gt;Tim Collinson has spent nearly 30 years building software, much of it on AWS, across manufacturing, oil and gas, and enterprise IT. Reach him at &lt;a href="mailto:trcollinson@gmail.com"&gt;trcollinson@gmail.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;P.S. If you deploy it and your numbers don't match mine, open an issue. I want to know. Deterministic simulations are only fun until they aren't.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>manufacturing</category>
      <category>dataengineering</category>
      <category>athena</category>
    </item>
  </channel>
</rss>
