<?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: zfbots Smith</title>
    <description>The latest articles on DEV Community by zfbots Smith (@zergskj).</description>
    <link>https://dev.to/zergskj</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%2F3720592%2Fca11889d-b43b-43c1-900a-8ea61a271817.jpg</url>
      <title>DEV Community: zfbots Smith</title>
      <link>https://dev.to/zergskj</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zergskj"/>
    <language>en</language>
    <item>
      <title>ERA5 is already gzipped. I still made it 47% smaller — losslessly.</title>
      <dc:creator>zfbots Smith</dc:creator>
      <pubDate>Sat, 17 Oct 2026 05:37:53 +0000</pubDate>
      <link>https://dev.to/zergskj/era5-is-already-gzipped-i-still-made-it-47-smaller-losslessly-1ajh</link>
      <guid>https://dev.to/zergskj/era5-is-already-gzipped-i-still-made-it-47-smaller-losslessly-1ajh</guid>
      <description>&lt;h1&gt;
  
  
  ERA5 is already gzipped. I still made it 47% smaller — losslessly.
&lt;/h1&gt;

&lt;h2&gt;
  
  
  First, the two baselines
&lt;/h2&gt;

&lt;p&gt;The easiest mistake to make when you write a compressor is to quote a flattering baseline while talking about a different one. So both go on the table first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Baseline&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;vs the &lt;code&gt;.nc&lt;/code&gt; on disk&lt;/strong&gt; (ERA5 ships gzip-compressed)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.86–1.88×&lt;/strong&gt;, &lt;strong&gt;46–47% smaller&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;vs uncompressed float32 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;median &lt;strong&gt;10.27×&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fyiopf9df790efdv4sk7e.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%2Fyiopf9df790efdv4sk7e.png" alt="Two baselines" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same files, two rulers. The orange bar is what you actually get back on disk.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both numbers are true, but they describe different things. &lt;strong&gt;The &lt;code&gt;.nc&lt;/code&gt; files are already gzip-compressed&lt;/strong&gt; — so the disk you actually reclaim is 47%, not 90%.&lt;/p&gt;

&lt;p&gt;And 10.27× matters because it is the same ruler everyone else uses. fpzip, zfp's reversible mode, and zstd + byte shuffle typically land at &lt;strong&gt;1.5–3×&lt;/strong&gt; on uncompressed scientific fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same ruler, an order of magnitude apart.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can compress already-compressed data
&lt;/h2&gt;

&lt;p&gt;Because ERA5's floats are not arbitrary floats.&lt;/p&gt;

&lt;p&gt;Every ERA5 variable is quantized to a fixed precision when it is generated — 10 m wind to 0.0625 (= 2⁻⁴), total precipitation to 2⁻¹⁴. Which means &lt;strong&gt;every value in a file is an integer multiple of the same 2^E&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;gzip cannot see this. It looks at 32-bit patterns, and the long run of zeros in the low mantissa bits is indistinguishable from random bytes to it: LZ77 finds no repeated pattern, and Huffman cannot give zeros a shorter code, because as far as gzip is concerned the high bytes are the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To us, that run of zeros is where all the information is.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding E
&lt;/h3&gt;

&lt;p&gt;Detection is purely algebraic, with no approximation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;float32 non-zero value = sig × 2^(exp−150)     # sig is the 24-bit significand
v is a multiple of 2^E  ⟺  tz(sig) + exp − 150 ≥ E
largest E valid for every value = min(tz(sig) + exp − 150)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;tz&lt;/code&gt; is the count of trailing zero bits. One trap: &lt;code&gt;exp&lt;/code&gt; must have the sign bit masked off first, or negative values get their exponent over-counted by 256 — we hit this bug, and it silently turns lossless compression into lossy compression.)&lt;/p&gt;

&lt;p&gt;Given E, the float is rewritten losslessly as an integer &lt;code&gt;k = v / 2^E&lt;/code&gt;. Because it is only a power-of-two scaling, &lt;strong&gt;this step has no rounding error&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the saving comes from
&lt;/h3&gt;

&lt;p&gt;A concrete example. Take a grid point whose value is 273.1875 in a file with E = −4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw float32:   273.1875
→ k = 273.1875 / 0.0625 = 4371
right neighbour k = 4373
longitude diff → 2
zigzag         → 4          = 0x00000004
byte planes    → top 3 bytes all zero, only the lowest byte is 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across a whole run of values like that, &lt;strong&gt;the high byte planes are almost entirely zero&lt;/strong&gt; and get eaten whole by the compressor; only the low planes actually cost bits.&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%2Fs9sx3uf2cmrradm3m34g.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%2Fs9sx3uf2cmrradm3m34g.png" alt="Bit pattern" width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The real bit pattern of 248.375: 13 trailing zeros in the mantissa are the gap quantization left behind. gzip looks straight past them.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The byte planes, on real data
&lt;/h3&gt;

&lt;p&gt;This is a slice of a &lt;strong&gt;real ERA5 2 m temperature field&lt;/strong&gt; (&lt;code&gt;201001_2t.nc&lt;/code&gt;, 24×32, detected E = −4). Left is the raw float32 byte planes, right is after lattice representation + differencing + zigzag:&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%2Fej5cw5bs83f6ei0k8s7q.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%2Fej5cw5bs83f6ei0k8s7q.png" alt="Byte planes" width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Byte plane&lt;/th&gt;
&lt;th&gt;raw float32, zero share&lt;/th&gt;
&lt;th&gt;after encoding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lowest byte&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;88.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;byte 1&lt;/td&gt;
&lt;td&gt;5.2%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;byte 2&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;highest byte&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that on the left the &lt;strong&gt;lowest byte is already 100% zero&lt;/strong&gt; — that is the quantization footprint — but &lt;strong&gt;the high bytes are pure entropy&lt;/strong&gt;, so gzip has nothing to work with. After encoding it flips: the top three planes go to zero, and only 89 of the 768 differenced residuals are still non-zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;float array
  → lattice representation (or order-preserving fallback)
  → 3-D separable Lorenzo differencing (time + lat + lon)
  → zigzag
  → byte-plane shuffle
  → zstd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lattice representation&lt;/strong&gt;: turns 32-bit floats into integers with a far smaller dynamic range. This is where most of the win is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Differencing&lt;/strong&gt;: assumes the field is continuous in time and space. First-order differences along time, then latitude, then longitude. The time lag is configurable — 24 to align with the diurnal cycle on hourly data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zigzag&lt;/strong&gt;: differences are signed, and negatives are all-ones in two's complement. zigzag interleaves ± into non-negative values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Byte-plane shuffle&lt;/strong&gt;: split each int32 into its 4 bytes and group like bytes together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zstd&lt;/strong&gt;: finish up.&lt;/li&gt;
&lt;/ul&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%2Fwamwkcn56f6q9lve0zig.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%2Fwamwkcn56f6q9lve0zig.png" alt="Pipeline" width="800" height="191"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How losslessness is guaranteed
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;quant_bits=0&lt;/code&gt;, &lt;code&gt;decode(encode(x))&lt;/code&gt; is &lt;strong&gt;byte-for-byte equal&lt;/strong&gt; to &lt;code&gt;x&lt;/code&gt;, including &lt;code&gt;-0.0&lt;/code&gt;, &lt;code&gt;NaN&lt;/code&gt;, and &lt;code&gt;Inf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The guarantee is not "detect the odd values and special-case them". It is that &lt;strong&gt;both encoding paths are bijections on bit patterns&lt;/strong&gt;: the lattice path relies on "power-of-two scaling is exact + per-block round-trip check", and the order-preserving path is a pure bit-pattern permutation. &lt;code&gt;NaN&lt;/code&gt;/&lt;code&gt;Inf&lt;/code&gt; are rejected by lattice detection outright and take the order-preserving path, so their bit patterns survive untouched.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-0.0&lt;/code&gt; is a separate trap: in the integer representation, &lt;code&gt;-0.0&lt;/code&gt; and &lt;code&gt;+0.0&lt;/code&gt; are both 0. So a sparse bitmap is stored alongside, and the sign bit is put back at decode time.&lt;/p&gt;

&lt;p&gt;The design principle throughout: &lt;strong&gt;prefer the slow path over a wrong answer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;The tool's own output (&lt;code&gt;gridpack info&lt;/code&gt;, real run):&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%2Fy3b0evh5ceebfbmfhlas.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%2Fy3b0evh5ceebfbmfhlas.png" alt="Real CLI output" width="800" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ERA5 coverage scan&lt;/td&gt;
&lt;td&gt;834 / 834 files, zero exceptions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lattice hit rate&lt;/td&gt;
&lt;td&gt;100% across all 834 files, no fallbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk saving (834-file benchmark)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.88×&lt;/strong&gt; measured, 46.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Production migration (measured)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;151 files: 83.44 GB &lt;code&gt;.nc&lt;/code&gt; → 44.70 GB &lt;code&gt;.gpz&lt;/code&gt;, 1.87×, 46.4% saved&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vs raw float32&lt;/td&gt;
&lt;td&gt;median 10.27×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;roundtrip / xarray tests&lt;/td&gt;
&lt;td&gt;46 + 10 items, ALL PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The production migration is the number I trust most, because it is the tool running unattended on real files, and it reproduces the benchmark independently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;&lt;code&gt;.nc&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;.gpz&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;Saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;migration, first run&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;26.23 GB&lt;/td&gt;
&lt;td&gt;14.04 GB&lt;/td&gt;
&lt;td&gt;1.87×&lt;/td&gt;
&lt;td&gt;46.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;migration, after restart&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;50.37 GB&lt;/td&gt;
&lt;td&gt;26.94 GB&lt;/td&gt;
&lt;td&gt;1.87×&lt;/td&gt;
&lt;td&gt;46.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all completed so far&lt;/td&gt;
&lt;td&gt;151&lt;/td&gt;
&lt;td&gt;83.44 GB&lt;/td&gt;
&lt;td&gt;44.70 GB&lt;/td&gt;
&lt;td&gt;1.87×&lt;/td&gt;
&lt;td&gt;46.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three separate samples, all 1.87×. The benchmark said 1.88×; production says 1.87×.&lt;/p&gt;

&lt;p&gt;Whole-file, end to end, including the decode round-trip:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;source &lt;code&gt;.nc&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;&lt;code&gt;.gpz&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;vs &lt;code&gt;.nc&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;vs raw bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200003_10u.nc&lt;/td&gt;
&lt;td&gt;589.1 MB&lt;/td&gt;
&lt;td&gt;298.5 MB&lt;/td&gt;
&lt;td&gt;1.97×&lt;/td&gt;
&lt;td&gt;10.35×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200003_2t.nc&lt;/td&gt;
&lt;td&gt;483.1 MB&lt;/td&gt;
&lt;td&gt;282.9 MB&lt;/td&gt;
&lt;td&gt;1.71×&lt;/td&gt;
&lt;td&gt;10.92×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200003_tp.nc&lt;/td&gt;
&lt;td&gt;189.0 MB&lt;/td&gt;
&lt;td&gt;108.8 MB&lt;/td&gt;
&lt;td&gt;1.74×&lt;/td&gt;
&lt;td&gt;28.40×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200001_10u.nc&lt;/td&gt;
&lt;td&gt;1443.5 MB&lt;/td&gt;
&lt;td&gt;747.0 MB&lt;/td&gt;
&lt;td&gt;1.93×&lt;/td&gt;
&lt;td&gt;3.94×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;radar.nc&lt;/td&gt;
&lt;td&gt;181.1 MB&lt;/td&gt;
&lt;td&gt;6.3 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28.83×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;28.60×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Performance: v0.4.0 → v0.5.3
&lt;/h2&gt;

&lt;p&gt;In the previous version of this write-up we listed three suspected optimizations and admitted we had not done them. We have now done them. The headline first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.56× faster end to end, with byte-identical output.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Of three suspects, only one was real
&lt;/h3&gt;

&lt;p&gt;Instrumentation had pointed at the &lt;code&gt;auto&lt;/code&gt; mode, &lt;code&gt;detect_lattice&lt;/code&gt;, and the zstd settings. In practice &lt;strong&gt;&lt;code&gt;detect_lattice&lt;/code&gt; was the only confirmed bottleneck.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It computes the trailing-zero count &lt;code&gt;tz(sig)&lt;/code&gt; once per non-zero value, and the original implementation was a per-value loop with an &lt;code&gt;np.where&lt;/code&gt; branch. Replacing it with a 16-bit lookup table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;v0.4.0&lt;/th&gt;
&lt;th&gt;v0.5.3&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;_block_exp&lt;/code&gt; (598 MB of values)&lt;/td&gt;
&lt;td&gt;13.96 s&lt;/td&gt;
&lt;td&gt;4.96 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.81×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;detect_lattice&lt;/code&gt; (whole file)&lt;/td&gt;
&lt;td&gt;19.6 s&lt;/td&gt;
&lt;td&gt;8.2 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.4×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;compress_file&lt;/code&gt; (end to end)&lt;/td&gt;
&lt;td&gt;148.6 s&lt;/td&gt;
&lt;td&gt;95.3 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.56×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2F8msc3py9lyhduzouzj5h.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%2F8msc3py9lyhduzouzj5h.png" alt="Performance" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;detect_lattice&lt;/code&gt;'s share of end-to-end wall clock dropped from 13% to 8.6%.&lt;/p&gt;

&lt;h3&gt;
  
  
  We changed the speed, not the format
&lt;/h3&gt;

&lt;p&gt;In a lossless compressor, the risk when optimizing is that you quietly change the output too. So every step was gated on three checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output bytes identical&lt;/strong&gt;: 296.1 MB, ratio 1.631×, &lt;code&gt;verify_file&lt;/code&gt; exact=True&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailing-zero function exhaustively verified&lt;/strong&gt;: every value in 1..2²⁰ plus 2×10⁵ random 32-bit values, matching the original implementation exactly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real data unchanged&lt;/strong&gt;: detected E still −4; all 4 modes round-trip bitexact; all 56 tests pass&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;CODEC_VERSION&lt;/code&gt; stays at 4 — &lt;strong&gt;files written by v0.4.0 read in v0.5.4, and vice versa.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The release process changed too
&lt;/h3&gt;

&lt;p&gt;Since 0.5.3 it goes through GitHub Actions: push a tag → version gate → build + &lt;code&gt;twine check&lt;/code&gt; + test suite → OIDC tokenless publish to PyPI. 0.5.4 came out of that pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"gridpack[xarray]&amp;gt;=0.5.4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While we were there, 0.5.4 fixed a real bug: &lt;code&gt;gridpack info&lt;/code&gt; and &lt;code&gt;gridpack verify&lt;/code&gt; &lt;strong&gt;crashed on every single invocation&lt;/strong&gt; in 0.5.3. &lt;code&gt;main()&lt;/code&gt; read &lt;code&gt;args.chunk_t&lt;/code&gt; unconditionally, but those two subcommands' parsers never defined it — an immediate &lt;code&gt;AttributeError&lt;/code&gt;. The fix routes the read through &lt;code&gt;getattr(..., None)&lt;/code&gt;. A command documented in your README that cannot run is worse than a slow one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, stated plainly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. It is not a general-purpose compressor.&lt;/strong&gt;&lt;br&gt;
The lattice win depends entirely on the data actually being on a quantization lattice. On-lattice: large win. Off-lattice: it falls back to the order-preserving path, which degrades to "differencing + byte-plane shuffle + zstd" — roughly what any general method does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Full-precision months drop to around 2×.&lt;/strong&gt;&lt;br&gt;
4 of the 834 files (200001/200002 &lt;code&gt;10u&lt;/code&gt;/&lt;code&gt;10v&lt;/code&gt;) have a lattice exponent of −16 rather than −4 — &lt;strong&gt;those months were not quantized; they are full-precision data&lt;/strong&gt;. They reach only 3.84–3.86× vs raw bytes, about 1.9× vs the &lt;code&gt;.nc&lt;/code&gt; on disk. Not a bug, a property of the data, but it drags the average down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It is slow.&lt;/strong&gt;&lt;br&gt;
Even 1.56× faster, it is still an &lt;strong&gt;hours-scale&lt;/strong&gt; job. Measured single-process throughput on one ERA5 field file is roughly 110–190 s; the completed 26.23 GB took 2 h 16 min, which works out to about &lt;strong&gt;3.2 MB/s&lt;/strong&gt;. Extrapolating linearly, the remaining 319 GB needs about &lt;strong&gt;28 hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Going faster means multiple processes, and whether that works depends on the memory allowance your environment gives you, not on the compressor. We hit &lt;code&gt;MemoryError: Unable to allocate 63.4 MiB&lt;/code&gt; in one restricted environment: the machine had 35 GB free, but the shared allowance for the whole subprocess batch was exhausted. Single-process in the same environment ran fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A lossy mode exists, and is off by default.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;quant_bits&amp;gt;0&lt;/code&gt; discards low bits of the order-preserving integer. Default is 0: strictly lossless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. It has mainly been validated on ERA5.&lt;/strong&gt;&lt;br&gt;
Plus one radar file. Whether other reanalysis products, model output, or GRIB2 land on a lattice, we have not tested.&lt;/p&gt;
&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"gridpack[xarray]&amp;gt;=0.5.4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gridpack compress input.nc &lt;span class="nt"&gt;-o&lt;/span&gt; output.gpz
gridpack decompress output.gpz &lt;span class="nt"&gt;-o&lt;/span&gt; restored.nc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;xarray plugin (registers &lt;code&gt;engine="gpz"&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;xarray&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;xr&lt;/span&gt;

&lt;span class="n"&gt;ds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.gpz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gpz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;another.gpz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/showkeyjar/gridpack" rel="noopener noreferrer"&gt;https://github.com/showkeyjar/gridpack&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One caveat for non-Chinese users: &lt;strong&gt;the CLI output and the README are currently Chinese-only.&lt;/strong&gt; That is a real barrier and it is on the list to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would like you to attack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;v0.4.0 is frozen&lt;/strong&gt; pending criticism; performance work goes in &lt;strong&gt;0.5.x&lt;/strong&gt; (now at 0.5.4, format-compatible with 0.4.0 in both directions). The four questions I most want answered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. What other data sits on a lattice?&lt;/strong&gt;&lt;br&gt;
We have only validated ERA5. GRIB2, other reanalysis products, model output, radar base data — if you have a sample, we would like to know the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What should happen to full-precision months?&lt;/strong&gt;&lt;br&gt;
The E=−16 files get none of the quantization dividend. Should they be detected and routed elsewhere, or just not compressed at all?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. How much is left in the entropy coder?&lt;/strong&gt;&lt;br&gt;
Right now the byte planes go through zstd. zstd is a general-purpose compressor and has no idea it is compressing floating-point residuals. How much would a context-mixing model add? We have not measured it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. How else can performance be recovered?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;detect_lattice&lt;/code&gt; is handled (1.56×). Two suspects remain: &lt;code&gt;auto&lt;/code&gt; mode tries three representations at 2.4× the cost of a single mode, and in practice it usually picks &lt;code&gt;dt&lt;/code&gt; for zero benefit; and verification reads the whole file back. If you have a better idea, say so.&lt;/p&gt;

&lt;p&gt;Data samples, benchmark results, or direct criticism of the design — issues are welcome.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this approach is useful to you, or if you have data that disproves it, we would like to hear about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>compression</category>
      <category>python</category>
      <category>performance</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
