<?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: Marco Pavanelli</title>
    <description>The latest articles on DEV Community by Marco Pavanelli (@marco_pavanelli).</description>
    <link>https://dev.to/marco_pavanelli</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%2F4155812%2Fc9377b96-0e17-40ba-b071-424e04e5635f.jpg</url>
      <title>DEV Community: Marco Pavanelli</title>
      <link>https://dev.to/marco_pavanelli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marco_pavanelli"/>
    <language>en</language>
    <item>
      <title>Serving Europe-wide air quality forecasts from a Parquet file on a bucket</title>
      <dc:creator>Marco Pavanelli</dc:creator>
      <pubDate>Fri, 02 Oct 2026 20:34:27 +0000</pubDate>
      <link>https://dev.to/marco_pavanelli/serving-europe-wide-air-quality-forecasts-from-a-parquet-file-on-a-bucket-287o</link>
      <guid>https://dev.to/marco_pavanelli/serving-europe-wide-air-quality-forecasts-from-a-parquet-file-on-a-bucket-287o</guid>
      <description>&lt;p&gt;Every morning the European Union publishes a 48-hour air quality forecast for the whole continent:&lt;br&gt;
five pollutants, one value every hour, on a grid of about 300,000 cells of roughly 10 km. It is&lt;br&gt;
free and open, produced by the &lt;a href="https://atmosphere.copernicus.eu/" rel="noopener noreferrer"&gt;Copernicus Atmosphere Monitoring Service&lt;/a&gt;&lt;br&gt;
(CAMS). It is also a 275 MB NetCDF file behind an API queue, which is not something you can show&lt;br&gt;
to your neighbour.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://coperni.azukibeans.dev" rel="noopener noreferrer"&gt;Coperni&lt;/a&gt; turns it into a map: pick an Italian municipality, a&lt;br&gt;
European city or your position, and you see the cells around you coloured against the EU limit&lt;br&gt;
values, hour by hour, plus a chart of the last week and the next two days.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2aa0nsyenbnc9u5r6kzp.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2aa0nsyenbnc9u5r6kzp.gif" alt="Coperni demo"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is about the boring part that I find most interesting: there is no database. The whole&lt;br&gt;
forecast for Europe is one Parquet file per day on a public bucket, and the web service reads only&lt;br&gt;
the few hundred kilobytes it needs with DuckDB over HTTP. It runs on Cloud Run, scales to zero,&lt;br&gt;
and costs about €0.25 a month at today's traffic.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape of the system
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cloud Scheduler (08:30 UTC)
  └─► Cloud Run Job: download 1 NetCDF (~275 MB) → convert → YYYY-MM-DD.parquet (~54 MB)
                                                             └─► public GCS bucket
Browser ─► Cloud Run service (Django + DuckDB) ─► HTTP range requests on the bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two containers built from the same Dockerfile: a daily job that writes, and a stateless web&lt;br&gt;
service that only reads. No Postgres, no Redis, no Celery, no credentials on the web side.&lt;/p&gt;
&lt;h2&gt;
  
  
  One request for all of Europe
&lt;/h2&gt;

&lt;p&gt;The Copernicus Atmosphere Data Store (ADS) limits each request by its &lt;em&gt;cost&lt;/em&gt;, and the costing&lt;br&gt;
endpoint is surprisingly honest about what that means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;245&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost is the number of &lt;em&gt;fields&lt;/em&gt;: variables × hours × days. Five pollutants × 49 hours × 1 day&lt;br&gt;
= 245. &lt;strong&gt;The area does not count.&lt;/strong&gt; A box around one city costs exactly as much as the whole&lt;br&gt;
domain, from North Africa to Scandinavia.&lt;/p&gt;

&lt;p&gt;My first instinct was to download small boxes on demand around the places people look at. That&lt;br&gt;
would have multiplied the cost and the queue time by the number of boxes. So the job asks for&lt;br&gt;
everything, once a day:&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cams-europe-air-quality-forecasts&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;variable&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;particulate_matter_2.5um&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;particulate_matter_10um&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nitrogen_dioxide&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ozone&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sulphur_dioxide&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ensemble&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;level&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;forecast&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;00:00&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                                &lt;span class="c1"&gt;# the run
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;leadtime_hour&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;     &lt;span class="c1"&gt;# the offsets, 0..48
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data_format&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;netcdf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;time&lt;/code&gt; vs &lt;code&gt;leadtime_hour&lt;/code&gt;. &lt;code&gt;time&lt;/code&gt; is when the forecast was issued, &lt;code&gt;leadtime_hour&lt;/code&gt; is how far&lt;br&gt;
ahead it looks. If you put 24 values in &lt;code&gt;time&lt;/code&gt;, you get 24 runs and a single instant, not a day of&lt;br&gt;
forecast. Ask me how I know.&lt;/p&gt;

&lt;p&gt;CAMS guarantees the 00 UTC run on the ADS by 08:00 UTC, so Cloud Scheduler starts the job at&lt;br&gt;
08:30. Queue plus download take about two minutes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Turning NetCDF into a file you can query over HTTP
&lt;/h2&gt;

&lt;p&gt;The grid is 420 × 700 cells × 49 hours ≈ 14.4 million rows. The Parquet layout is deliberately&lt;br&gt;
plain: one row per cell and hour, one column per pollutant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;column&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lat&lt;/code&gt;, &lt;code&gt;lon&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;smallint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;degrees × 100 (cell centres)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ora&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utinyint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hour offset from 00 UTC, 0..48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pm2p5&lt;/code&gt;, &lt;code&gt;pm10&lt;/code&gt;, &lt;code&gt;no2&lt;/code&gt;, &lt;code&gt;o3&lt;/code&gt;, &lt;code&gt;so2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;smallint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;µg/m³ × 10, null if missing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Quantised integers
&lt;/h3&gt;

&lt;p&gt;Storing coordinates ×100 and concentrations ×10 as 16-bit integers instead of 32-bit floats does&lt;br&gt;
two things. Files are smaller, and the columns compress much better because there is no float&lt;br&gt;
noise in the low bits. With zstd a day goes from ~275 MB of NetCDF to &lt;strong&gt;~54 MB of Parquet&lt;/strong&gt;. I&lt;br&gt;
compared every value against the original: the error is at most 0.05 µg/m³, far below the model's&lt;br&gt;
own uncertainty.&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="n"&gt;scaled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nan_to_num&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32767&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scaled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isnan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Sorting is the index
&lt;/h3&gt;

&lt;p&gt;This is the trick that makes everything else work. Parquet splits a file into &lt;em&gt;row groups&lt;/em&gt; and&lt;br&gt;
stores min/max statistics for every column of every group. A reader that filters on&lt;br&gt;
&lt;code&gt;lat BETWEEN … AND lon BETWEEN …&lt;/code&gt; can skip every group whose ranges cannot match, without&lt;br&gt;
downloading it.&lt;/p&gt;

&lt;p&gt;Statistics only help if the rows inside a group are close together in space. If you sort by&lt;br&gt;
latitude and then longitude, each group is a thin horizontal stripe across the whole continent.&lt;br&gt;
Its &lt;code&gt;lat&lt;/code&gt; range is tiny but its &lt;code&gt;lon&lt;/code&gt; range covers everything from Portugal to Russia, so a query&lt;br&gt;
for Milan still has to touch every stripe at Milan's latitude.&lt;/p&gt;

&lt;p&gt;So the rows are sorted by &lt;strong&gt;1° × 1° tile first&lt;/strong&gt;, then latitude, longitude and hour:&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="n"&gt;g_lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g_lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g_hour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ravel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meshgrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indexing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ij&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lexsort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;g_hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g_lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g_lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g_lon&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g_lat&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# last key = primary
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tile is 10 × 10 cells × 49 hours = 4,900 rows. With row groups of 24,500 rows, each group holds&lt;br&gt;
five neighbouring tiles: a compact 1° × 5° block of the map. The file ends up with ~590 row groups,&lt;br&gt;
and a typical map view, 50 cells wide around a place, overlaps &lt;strong&gt;about 4 of them&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two things that bit me
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Longitudes are 0..360.&lt;/strong&gt; CAMS stores Western Europe at 335..360, so the axis is not monotonic,
and xarray's &lt;code&gt;sel(..., method='nearest')&lt;/code&gt; fails on it. Converting to -180..180 and sorting
fixes it:
&lt;code&gt;ds.assign_coords(longitude=((ds.longitude + 180) % 360) - 180).sortby(['latitude', 'longitude'])&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no &lt;code&gt;forecast_reference_time&lt;/code&gt;.&lt;/strong&gt; In the NetCDF that the ADS returns, &lt;code&gt;time&lt;/code&gt; is an
offset axis (&lt;code&gt;timedelta64&lt;/code&gt;), and the base date is only in &lt;code&gt;time.attrs['long_name']&lt;/code&gt;. Since the
job knows which day it asked for, it names the file after it and stores just the offset.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Reading 0.2 MB out of 54
&lt;/h2&gt;

&lt;p&gt;The bucket is public. Copernicus data is open, and the attribution is on the site. So the web&lt;br&gt;
service needs no credentials: DuckDB reads &lt;code&gt;https://storage.googleapis.com/&amp;lt;bucket&amp;gt;/cams/&amp;lt;day&amp;gt;.parquet&lt;/code&gt;&lt;br&gt;
like any other URL, and uses HTTP range requests to fetch the footer, then only the row groups that&lt;br&gt;
survive the statistics.&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="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;
    SELECT lat, lon, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pollutant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; FROM read_parquet(?)
    WHERE ora = ? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?
      AND &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pollutant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; IS NOT NULL&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat_max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon_max&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is about &lt;strong&gt;0.2 MB per pollutant per view&lt;/strong&gt;. The column name is interpolated, but only after&lt;br&gt;
checking it against a fixed whitelist of the five pollutants.&lt;/p&gt;

&lt;p&gt;A few details matter on Cloud Run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One DuckDB connection per process, one cursor per thread.&lt;/strong&gt; The Parquet metadata cache and the
HTTP metadata cache live in the database object, so all threads share them:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;  &lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SET enable_http_metadata_cache = true&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SET enable_object_cache = true&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;Gunicorn runs 1 worker × 8 threads, and Cloud Run concurrency is 8. More workers would mean&lt;br&gt;
  more cold caches.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The files are immutable.&lt;/strong&gt; A day's run never changes, so the caches never need invalidating
while an instance is warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buckets cannot be listed over plain HTTPS.&lt;/strong&gt; The job writes a small &lt;code&gt;indice.json&lt;/code&gt; next to the
files (&lt;code&gt;{"giorni": ["2026-09-10", …], "ore": 49}&lt;/code&gt;), and the web service reads it every five
minutes to know which days exist. Deletion is left to a bucket lifecycle rule (21 days).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured in production: cold start ~3.7 s, first read of a new day's file ~1 s, then 0.1–0.6 s per&lt;br&gt;
map request.&lt;/p&gt;

&lt;p&gt;The chart is the heaviest query. It averages the window over the first 24 hours of each run from&lt;br&gt;
the last 7 days, plus the whole latest run, in a single &lt;code&gt;read_parquet([...8 urls...], filename = true)&lt;/code&gt;&lt;br&gt;
with a &lt;code&gt;GROUP BY&lt;/code&gt;. It takes 1–4 s, so the response is cached for 15 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Prices for &lt;code&gt;europe-west1&lt;/code&gt;. Usage per visit was measured on the live site.&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;€/month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily job (~2 min, 1 vCPU, 2 GiB)&lt;/td&gt;
&lt;td&gt;0.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bucket (~1 GB of Parquet)&lt;/td&gt;
&lt;td&gt;0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container images in Artifact Registry&lt;/td&gt;
&lt;td&gt;~0.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduler, Secret Manager, logging&lt;/td&gt;
&lt;td&gt;0 (free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Traffic adds almost nothing until Cloud Run's free tier runs out: about &lt;strong&gt;€0.25/month at 1,000&lt;br&gt;
visits, under €3 at 100,000&lt;/strong&gt;. At a million visits the estimate is around €60, 70% of it CPU time.&lt;br&gt;
And since &lt;code&gt;max-instances&lt;/code&gt; is 3, there is a hard ceiling on what a traffic spike can cost.&lt;/p&gt;

&lt;p&gt;Two cheap wins along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gzip.&lt;/strong&gt; Cloud Run does not compress responses for you. Adding Django's &lt;code&gt;GZipMiddleware&lt;/code&gt; cut the
map page by 73%, the grid API by 81% and the chart API by 89% (52 KB → 6 KB). There is no BREACH
exposure: no forms, no sessions, no secrets in the responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The basemap was the real bill.&lt;/strong&gt; I started with CARTO raster tiles. Their free tier is 5 million
tiles a month, which at ~60 tiles per visit is about 80,000 visits, and the next plan is $500 a
month: more than all of Google Cloud combined at a million visits. The API key was also visible
in the page source. I switched to &lt;a href="https://openfreemap.org" rel="noopener noreferrer"&gt;OpenFreeMap&lt;/a&gt; vector tiles drawn by
MapLibre GL under Leaflet: no key, no limits, and about 10 requests per visit instead of 70. The
trade-offs are ~290 KB more JavaScript on the first load, a WebGL requirement, and no SLA:
OpenFreeMap is one person running on donations. If it goes away, the same styles can be
self-hosted as PMTiles on the same bucket.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Would I do it again?
&lt;/h2&gt;

&lt;p&gt;For read-only data that is produced in batches, yes. "Parquet on a bucket + DuckDB" behaves like a&lt;br&gt;
database with exactly one index, the sort order, and no server to run. The design work moves from&lt;br&gt;
schema and indexes to &lt;em&gt;how you sort the rows&lt;/em&gt;, and that turned out to be a one-line &lt;code&gt;lexsort&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What it does not give you: writes, ad-hoc queries that cut across the sort order cheaply, or&lt;br&gt;
sub-100 ms latency on a cold instance. For a public map that changes once a day, none of those&lt;br&gt;
mattered.&lt;/p&gt;

&lt;p&gt;The code is on GitHub under the Apache 2.0 licence:&lt;br&gt;
&lt;a href="https://github.com/azuki-beans/coperni" rel="noopener noreferrer"&gt;azuki-beans/coperni&lt;/a&gt;. The&lt;br&gt;
&lt;a href="https://github.com/azuki-beans/coperni/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;architecture notes&lt;/a&gt; include&lt;br&gt;
every &lt;code&gt;gcloud&lt;/code&gt; command to deploy your own copy. There are a few&lt;br&gt;
&lt;a href="https://github.com/azuki-beans/coperni/issues" rel="noopener noreferrer"&gt;good first issues&lt;/a&gt;, including a German&lt;br&gt;
translation.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Generated using Copernicus Atmosphere Monitoring Service information (2026).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>duckdb</category>
      <category>parquet</category>
      <category>django</category>
    </item>
  </channel>
</rss>
