<?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: Christoph Dieck</title>
    <description>The latest articles on DEV Community by Christoph Dieck (@cdieck88).</description>
    <link>https://dev.to/cdieck88</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%2F4073607%2F596a09f7-799c-4bbd-b0bf-fb737de650de.jpg</url>
      <title>DEV Community: Christoph Dieck</title>
      <link>https://dev.to/cdieck88</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cdieck88"/>
    <language>en</language>
    <item>
      <title>Goodbye Fritz!Box, Hello Ubiquiti</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Fri, 18 Sep 2026 08:50:00 +0000</pubDate>
      <link>https://dev.to/cdieck88/goodbye-fritzbox-hello-ubiquiti-b6m</link>
      <guid>https://dev.to/cdieck88/goodbye-fritzbox-hello-ubiquiti-b6m</guid>
      <description>&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%2F2axpbfkseomnh6olaa3h.webp" 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%2F2axpbfkseomnh6olaa3h.webp" alt="Ubiquiti Cloud Gateway Ultra in action"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why switch at all?
&lt;/h2&gt;

&lt;p&gt;The Fritz!Box 7690 is a capable device. Telephony, Wi-Fi, DECT, smart home, all in one box. That is also the problem. I want a network I actually control: VLANs, firewall rules, a proper dashboard. The Fritz!Box does a bit of everything; I want something that does the routing well and leaves the rest to me.&lt;/p&gt;

&lt;p&gt;On top of that, I kept running into performance problems over the past few months. The Wi-Fi would slow down for no obvious reason, in the middle of the day, with the same number of devices as always. Streaming stuttered, video calls lagged. A reboot fixed it every time, but who wants to restart their router on a schedule? It felt more and more like the box was overwhelmed by everything it had to handle: smart home devices, DECT phones, VPN, guest network, AdGuard as DNS. At some point it just could not keep up.&lt;/p&gt;

&lt;p&gt;I went with the Ubiquiti Cloud Gateway Ultra (UCG Ultra). Small, reasonably priced, and the UniFi ecosystem has everything a homelab network needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: reading the Fritz!Box config
&lt;/h2&gt;

&lt;p&gt;Before unplugging the old router, you should know what it actually does. The Fritz!Box has an export function under &lt;strong&gt;System → Backup → Save&lt;/strong&gt;. The result is an &lt;code&gt;.export&lt;/code&gt; file, basically a massive text dump of every configuration file on the device.&lt;/p&gt;

&lt;p&gt;The relevant WAN settings live inside &lt;code&gt;ar7.cfg&lt;/code&gt;. Opening that file for the first time feels a bit like doing your taxes. Thousands of lines, cryptic variable names. But the handful of values you actually need are easy to spot once you know what to look for.&lt;/p&gt;

&lt;p&gt;One thing to keep in mind: the export file is encrypted if you set a password during export. Passwords and credentials inside the config are additionally encoded. If you need to extract plaintext credentials from the file (e.g. SIP credentials for VoIP), you can use my &lt;a href="https://www.slashgordon.link/tools/fritzbox-config-decrypt/" rel="noopener noreferrer"&gt;Fritz!Box Config Decrypt Tool&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: understanding the WAN settings
&lt;/h2&gt;

&lt;p&gt;My ISP is Buchholz Digital (BuDi) with an FTTH connection. The setup is refreshingly simple: no PPPoE, no credentials. The router connects via Ethernet to the ONT, tags VLAN 700, and gets a public IP through DHCP.&lt;/p&gt;

&lt;p&gt;Here is the relevant section from the Fritz!Box config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;dslifaces&lt;/span&gt; {
        &lt;span class="n"&gt;enabled&lt;/span&gt; = &lt;span class="n"&gt;yes&lt;/span&gt;;
        &lt;span class="n"&gt;name&lt;/span&gt; = &lt;span class="s2"&gt;"internet"&lt;/span&gt;;
        &lt;span class="n"&gt;dsl_encap&lt;/span&gt; = &lt;span class="n"&gt;dslencap_ether&lt;/span&gt;;
        &lt;span class="n"&gt;dslinterfacename&lt;/span&gt; = &lt;span class="s2"&gt;"dsl"&lt;/span&gt;;
        &lt;span class="n"&gt;no_masquerading&lt;/span&gt; = &lt;span class="n"&gt;no&lt;/span&gt;;
        &lt;span class="n"&gt;stackmode&lt;/span&gt; = &lt;span class="n"&gt;stackmode_ipv4only&lt;/span&gt;;
        &lt;span class="n"&gt;vlancfg&lt;/span&gt; {
                &lt;span class="n"&gt;vlanencap&lt;/span&gt; = &lt;span class="n"&gt;vlanencap_fixed_prio&lt;/span&gt;;
                &lt;span class="n"&gt;tagtype&lt;/span&gt; = &lt;span class="n"&gt;vlantagtype_customer&lt;/span&gt;;
                &lt;span class="n"&gt;vlanid&lt;/span&gt; = &lt;span class="m"&gt;700&lt;/span&gt;;
                &lt;span class="n"&gt;vlanprio&lt;/span&gt; = &lt;span class="m"&gt;0&lt;/span&gt;;
        }
        &lt;span class="n"&gt;ppptarget&lt;/span&gt; = &lt;span class="s2"&gt;"internet"&lt;/span&gt;;
        &lt;span class="n"&gt;mtu&lt;/span&gt; = &lt;span class="m"&gt;0&lt;/span&gt;;
        &lt;span class="n"&gt;etherencapcfg&lt;/span&gt; {
                &lt;span class="n"&gt;use_dhcp&lt;/span&gt; = &lt;span class="n"&gt;yes&lt;/span&gt;;
        }
        &lt;span class="n"&gt;stay_always_online&lt;/span&gt; = &lt;span class="n"&gt;yes&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key values at a glance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connection type&lt;/td&gt;
&lt;td&gt;DHCP (no PPPoE)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internet VLAN&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VoIP VLAN&lt;/td&gt;
&lt;td&gt;650&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TR-069 VLAN&lt;/td&gt;
&lt;td&gt;550&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4&lt;/td&gt;
&lt;td&gt;via DHCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv6&lt;/td&gt;
&lt;td&gt;Automatic (dual-stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MTU&lt;/td&gt;
&lt;td&gt;1500 (auto)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three VLANs in total. For the UCG Ultra, only VLAN 700 matters. VLAN 650 handles telephony and VLAN 550 is for remote management by the ISP. The UCG Ultra needs neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: configuring the UCG Ultra
&lt;/h2&gt;

&lt;p&gt;The UniFi Network Controller makes this surprisingly painless:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Under Internet/WAN, set connection type to DHCPv4.&lt;/li&gt;
&lt;li&gt;Set VLAN ID to 700. This is the one that matters.&lt;/li&gt;
&lt;li&gt;MAC Address Override is there if the ISP expects the Fritz!Box MAC. I did not need it, but it is good to know it exists. You can find the old MAC in the export file under &lt;code&gt;user.cfg&lt;/code&gt;, where the Fritz!Box itself is listed as a device:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;usercfg&lt;/span&gt; {
        &lt;span class="n"&gt;users2&lt;/span&gt; {
                &lt;span class="n"&gt;type&lt;/span&gt; = &lt;span class="n"&gt;internet_user_ip&lt;/span&gt;;
                &lt;span class="n"&gt;landeviceUID&lt;/span&gt; = &lt;span class="m"&gt;8511&lt;/span&gt;;
                &lt;span class="n"&gt;mac&lt;/span&gt; = &lt;span class="m"&gt;34&lt;/span&gt;:&lt;span class="n"&gt;E1&lt;/span&gt;:&lt;span class="n"&gt;A9&lt;/span&gt;:&lt;span class="m"&gt;24&lt;/span&gt;:&lt;span class="m"&gt;97&lt;/span&gt;:&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="n"&gt;D&lt;/span&gt;;
        }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MAC is also on the sticker on the bottom of the Fritz!Box, or in the web interface under Home Network → Network.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set IPv6 to "Auto" or "DHCPv6" for dual-stack.&lt;/li&gt;
&lt;li&gt;Set DNS to custom servers (in my case &lt;code&gt;192.168.50.250&lt;/code&gt; for AdGuard and &lt;code&gt;1.1.1.1&lt;/code&gt; as fallback).&lt;/li&gt;
&lt;/ol&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%2Fkckc9yd90kzrfdu4x462.webp" 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%2Fkckc9yd90kzrfdu4x462.webp" alt="UniFi UCG Ultra WAN setup with VLAN ID and MAC override"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cable from the ONT into the WAN port of the UCG Ultra, configure VLAN 700, done. It worked on the first try. I was almost disappointed there was no drama.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: migrating LAN settings
&lt;/h2&gt;

&lt;p&gt;I carried the Fritz!Box settings over to the LAN side. The subnet is &lt;code&gt;192.168.50.0/24&lt;/code&gt; with gateway &lt;code&gt;192.168.50.1&lt;/code&gt; and a DHCP range from &lt;code&gt;192.168.50.20&lt;/code&gt; to &lt;code&gt;192.168.50.200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Static DHCP leases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;th&gt;MAC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NAS&lt;/td&gt;
&lt;td&gt;192.168.50.2&lt;/td&gt;
&lt;td&gt;90:09:D0:35:57:8B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxmox&lt;/td&gt;
&lt;td&gt;192.168.50.24&lt;/td&gt;
&lt;td&gt;98:FA:9B:2E:20:CB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker host&lt;/td&gt;
&lt;td&gt;192.168.50.46&lt;/td&gt;
&lt;td&gt;BC:24:11:A5:65:6F&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the IPv6 ULA prefix I set &lt;code&gt;fd15:d91c:273f::/48&lt;/code&gt; manually on the UCG Ultra so that local IPv6 addresses (like AdGuard on &lt;code&gt;fd15:d91c:273f::250&lt;/code&gt;) stay stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the ISP notice the switch?
&lt;/h2&gt;

&lt;p&gt;Yes, and it does not matter.&lt;/p&gt;

&lt;p&gt;The MAC address on the WAN port is different. With a DHCP connection like mine that is not a problem: the DHCP server just hands out a new lease. If it does cause trouble, the UCG Ultra lets you override the MAC. The DHCP hostname also changes, since the Fritz!Box sends &lt;code&gt;fritz.box&lt;/code&gt; and the UCG Ultra sends something else. That is purely cosmetic.&lt;/p&gt;

&lt;p&gt;TR-069 goes away. The Fritz!Box was remotely managed by the ISP via TR-069 on VLAN 550, and the UCG Ultra does not support it, so the ISP loses remote access to the router. In my case a quick phone call to BuDi sorted it out. Everything else stays the same: VLAN 700 works identically, DHCP-based IP assignment is device-independent, and the fiber connection at the ONT is unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about VoIP?
&lt;/h2&gt;

&lt;p&gt;The Fritz!Box handled telephony over VLAN 650, and the UCG Ultra cannot do SIP. If you still need a landline, you can run the Fritz!Box as an IP client behind the UCG Ultra just for telephony, or use a standalone SIP phone or adapter.&lt;/p&gt;

&lt;p&gt;I went with the first option. The Fritz!Box now sits on a LAN port of the UCG Ultra in IP client mode and only handles DECT and phone calls. To set that up, go to Internet → Account Information → Internet Access in the Fritz!Box UI and switch to "Use existing internet connection (IP client)". Retirement for the Fritz!Box, but with a small side gig.&lt;/p&gt;

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

&lt;p&gt;The Fritz!Box export files are the key to this. All WAN settings live in &lt;code&gt;ar7.cfg&lt;/code&gt;, and once you have fought through the text dump you can find the relevant values in a few minutes. For FTTH with BuDi you only need three things: VLAN 700, DHCP, no PPPoE.&lt;/p&gt;

&lt;p&gt;Swapping the router on a DHCP connection is painless. No call to the ISP, no activation, no waiting. Plug it in, configure the VLAN, it works. TR-069 going away feels more like a feature than a bug, and the Fritz!Box lives on as a dedicated telephony device in IP client mode instead of going in the bin.&lt;/p&gt;

&lt;p&gt;The 35-watt roommate now has a new neighbor in the network cabinet. Part 8 is about the UniFi VLAN setup, because once you have a proper router you might as well do proper network segmentation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://www.slashgordon.link/post/homelab-fritzbox-to-ubiquiti-ucg-ultra/" rel="noopener noreferrer"&gt;www.slashgordon.link&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ubiquiti</category>
      <category>ucgultra</category>
      <category>fritzbox</category>
      <category>ftth</category>
    </item>
    <item>
      <title>Ads? Never Heard of Them.</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:57:21 +0000</pubDate>
      <link>https://dev.to/cdieck88/ads-never-heard-of-them-23db</link>
      <guid>https://dev.to/cdieck88/ads-never-heard-of-them-23db</guid>
      <description>&lt;h2&gt;
  
  
  The goal: quiet across the whole network
&lt;/h2&gt;

&lt;p&gt;Ads on the internet are exhausting, on phones, on smart TVs, everywhere. For anyone running a homeserver the obvious answer is a network-wide DNS blocker. Pi-hole was the default choice for years, but I went with AdGuard Home. It feels more modern, does DNS-over-HTTPS and DNS-over-TLS out of the box, and I like the interface more.&lt;/p&gt;

&lt;p&gt;Sounds easy enough, right? Start the container, done. That was optimistic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final boss: port 53
&lt;/h2&gt;

&lt;p&gt;My first attempt was to run the container on the &lt;code&gt;host&lt;/code&gt; network. That failed immediately because of port 53.&lt;/p&gt;

&lt;p&gt;Port 53 is the standard port for DNS requests, and on almost every modern Linux server, my Ubuntu host included, it is already taken by &lt;code&gt;systemd-resolved&lt;/code&gt;. The container could not start.&lt;/p&gt;

&lt;p&gt;I could have disabled &lt;code&gt;systemd-resolved&lt;/code&gt;, but poking around in the host system goes against how I want to run this machine. The Docker host should stay as vanilla as possible. So, plan B.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: Macvlan
&lt;/h2&gt;

&lt;p&gt;A Docker Macvlan network is made for cases like this. The container behaves like a real physical device on the network: it gets its own MAC address and, more importantly, its own IP address from the router, independent of the host server. No more port conflict on port 53.&lt;/p&gt;

&lt;p&gt;My network is dual-stack, so I did it properly. The container gets a dedicated IPv4 address (&lt;code&gt;192.168.50.250&lt;/code&gt;) and a dedicated IPv6 address, a stable ULA starting with &lt;code&gt;fd...&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The next problem: Traefik
&lt;/h2&gt;

&lt;p&gt;The DNS blocker worked now, but I wanted the web dashboard on my own domain (&lt;code&gt;adguard.dieck-labs.de&lt;/code&gt;) behind Traefik as a reverse proxy, instead of typing IP addresses into the browser. This is where it got annoying.&lt;/p&gt;

&lt;h3&gt;
  
  
  Network isolation
&lt;/h3&gt;

&lt;p&gt;For Traefik to reach the container, both services need to share the same internal Docker network, in my case &lt;code&gt;apps&lt;/code&gt;. But a container attached to Macvlan is isolated from the normal Docker world. So the container has to join both networks: Macvlan for DNS traffic from the LAN, &lt;code&gt;apps&lt;/code&gt; for Traefik.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 502 Bad Gateway
&lt;/h3&gt;

&lt;p&gt;After wiring it into Traefik I got nothing but &lt;code&gt;502 Bad Gateway&lt;/code&gt;. Hours of debugging.&lt;/p&gt;

&lt;p&gt;The cause was subtle: if you do the initial AdGuard Home setup via the Macvlan IP, it often configures itself to listen only on that one interface, so requests coming from Traefik through the internal &lt;code&gt;apps&lt;/code&gt; network are ignored. In &lt;code&gt;AdGuardHome.yaml&lt;/code&gt; I had to bind the web UI to &lt;code&gt;0.0.0.0&lt;/code&gt; so it listens on all interfaces, and move the admin UI to an uncommon port like &lt;code&gt;8087&lt;/code&gt;. Then Traefik can reach it while DNS keeps using port 53.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;This is what the traffic flow looks like now:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
    subgraph "Home Network (192.168.50.0/24)"
        Phone[📱 Smartphone] -- DNS Query --&amp;gt; AGH
        TV[📺 Smart TV] -- DNS Query --&amp;gt; AGH
        Laptop[💻 Laptop] -- DNS Query --&amp;gt; AGH
        FB[🌐 Fritz!Box&amp;lt;br/&amp;gt;DHCP Server] -. distributes DNS .-&amp;gt; Phone &amp;amp; TV &amp;amp; Laptop
    end

    subgraph "Lenovo M920q (Docker)"
        AGH[AdGuard Home&amp;lt;br/&amp;gt;192.168.50.250]
        TF[Traefik&amp;lt;br/&amp;gt;Reverse Proxy]

        AGH -- apps network --&amp;gt; TF
    end

    subgraph "Internet"
        User((👤 Me)) -- HTTPS --&amp;gt; CF[Cloudflare]
        CF -- Tunnel --&amp;gt; TF
        TF -- Port 8087 --&amp;gt; AGH

        AGH -- DNS Upstream --&amp;gt; DNS[Cloudflare DNS&amp;lt;br/&amp;gt;1.1.1.1]
    end

    style AGH fill:#4CAF50,stroke:#2E7D32,color:#fff&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  The config: two networks, one container
&lt;/h2&gt;

&lt;p&gt;Here is the final Docker Compose file (&lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/adguard.yml" rel="noopener noreferrer"&gt;adguard.yml in the repo&lt;/a&gt;), the result of too many evenings staring at terminal output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.9"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adguardhome&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adguard/adguardhome:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adguardhome&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Network 1: DNS traffic from the home network&lt;/span&gt;
      &lt;span class="na"&gt;macvlan_net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ipv4_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.50.250&lt;/span&gt;
        &lt;span class="na"&gt;ipv6_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fd15:d91c:273f:0::250&lt;/span&gt;
      &lt;span class="c1"&gt;# Network 2: Communication with Traefik&lt;/span&gt;
      &lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Docker-managed working data (logs etc.)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;adguard_work:/opt/adguardhome/work&lt;/span&gt;
      &lt;span class="c1"&gt;# IMPORTANT: local bind mount for writable config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./adguard:/opt/adguardhome/conf&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.docker.network=apps"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.adguard.rule=Host(`adguard.dieck-labs.de`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.adguard.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.adguard.tls.certresolver=myresolver"&lt;/span&gt;
      &lt;span class="c1"&gt;# Traefik talks to the admin UI on this custom port&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.adguard.loadbalancer.server.port=8087"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;macvlan_net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;macvlan&lt;/span&gt;
    &lt;span class="na"&gt;enable_ipv6&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;driver_opts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;parent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ens18&lt;/span&gt; &lt;span class="c1"&gt;# Physical host interface&lt;/span&gt;
    &lt;span class="na"&gt;ipam&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;subnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.50.0/24&lt;/span&gt;
          &lt;span class="na"&gt;gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.50.1&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;subnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fd15:d91c:273f:0::/64&lt;/span&gt;
          &lt;span class="na"&gt;gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fd15:d91c:273f:0::1&lt;/span&gt;
  &lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# Existing Traefik network&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adguard_work&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The config trick
&lt;/h3&gt;

&lt;p&gt;One detail matters: &lt;code&gt;AdGuardHome.yaml&lt;/code&gt; in the local &lt;code&gt;./adguard&lt;/code&gt; directory needs a small manual change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:8087&lt;/span&gt;  &lt;span class="c1"&gt;# Listen on all interfaces&lt;/span&gt;
&lt;span class="na"&gt;dns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bind_hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.50.250&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fd15:d91c:273f:0::250"&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;53&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I first tried mounting the config via Docker &lt;code&gt;configs&lt;/code&gt;. Clean, and satisfying on paper. But it also made the file read-only, so the AdGuard dashboard could no longer save changes. The old-fashioned bind mount turned out to be the practical choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeding the Fritz!Box
&lt;/h2&gt;

&lt;p&gt;A network-wide ad blocker is useless if no device uses it. Instead of changing DNS settings on every phone, laptop, and TV by hand, I let the Fritz!Box hand out AdGuard as the default DNS server over DHCP. In the Fritz!Box UI, go to &lt;code&gt;http://fritz.box&lt;/code&gt; → Internet → Account Information → DNS Server, enable "Use other DNSv4 servers", and enter &lt;code&gt;192.168.50.250&lt;/code&gt; as the preferred DNS server. For IPv6 DNS, add &lt;code&gt;fd15:d91c:273f:0::250&lt;/code&gt; as well. Then briefly toggle Wi-Fi off and on so devices pick up the new settings.&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%2F51nydmefr0cj9osvfh21.webp" 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%2F51nydmefr0cj9osvfh21.webp" alt="DNS settings in the Fritz!Box" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Open the AdGuard query log and you can watch tracking domains get dropped in real time.&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%2F1a67fn73dw91smdyve2f.webp" 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%2F1a67fn73dw91smdyve2f.webp" alt="AdGuard Dashboard" width="800" height="773"&gt;&lt;/a&gt; The YouTube app on the smart TV feels snappier because it spends less time on ad requests, and every device in the house sees less tracking.&lt;/p&gt;

&lt;p&gt;A few things I took away: Macvlan is the cleanest way around a host-level port conflict like port 53; a two-network Docker setup only works reliably if the service binding is right; and a pragmatic bind mount sometimes beats a cleaner read-only config. The 35-watt roommate now doubles as the bouncer for the home network.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://www.slashgordon.link/post/homelab-adguard-docker-macvlan-traefik/" rel="noopener noreferrer"&gt;www.slashgordon.link&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>adguard</category>
      <category>docker</category>
      <category>macvlan</category>
      <category>traefik</category>
    </item>
    <item>
      <title>Monitoring, or 'Why is the internet constantly asking for my .php files?</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Mon, 14 Sep 2026 14:36:15 +0000</pubDate>
      <link>https://dev.to/cdieck88/monitoring-or-why-is-the-internet-constantly-asking-for-my-php-files-3m6p</link>
      <guid>https://dev.to/cdieck88/monitoring-or-why-is-the-internet-constantly-asking-for-my-php-files-3m6p</guid>
      <description>&lt;p&gt;A Grafana dashboard sounds like an enterprise thing, far too much hassle for a private homelab. I used to think so too.&lt;/p&gt;

&lt;p&gt;Terms like &lt;em&gt;Prometheus&lt;/em&gt;, &lt;em&gt;Exporter&lt;/em&gt;, &lt;em&gt;Loki&lt;/em&gt;, and &lt;em&gt;Promtail&lt;/em&gt; sound like a lot of overhead and a steep learning curve. But I started playing around with it for fun, and thanks to Docker the whole thing was up and running faster than you can spell "monitoring". This is how I ended up monitoring my setup and turning away bots at the front door.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do I actually want to know?
&lt;/h2&gt;

&lt;p&gt;You might remember my setup from the article about &lt;a href="https://www.slashgordon.link/de/post/homelab-cloudflare-tunnel-docker-stacks/" rel="noopener noreferrer"&gt;Cloudflare Tunnels and Docker Stacks&lt;/a&gt;. I want visibility into my Nginx instances, the Proxmox host, the Ubuntu server (including SSH logs), and my Docker containers.&lt;/p&gt;

&lt;p&gt;That's a fair number of endpoints, but there's a matching exporter for almost all of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pve-exporter fetches metrics directly from the Proxmox API.&lt;/li&gt;
&lt;li&gt;node-exporter is the classic for Linux host metrics (CPU, RAM, disk).&lt;/li&gt;
&lt;li&gt;cAdvisor looks inside Docker containers and cgroups.&lt;/li&gt;
&lt;li&gt;promtail is the log vacuum that ships everything to Loki.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The backend is Loki for logs and Prometheus for time-series metrics. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph LR
    classDef grafana fill:#F47A20,stroke:#fff,stroke-width:2px,color:#fff;
    classDef prometheus fill:#E6522C,stroke:#fff,stroke-width:2px,color:#fff;
    classDef loki fill:#000,stroke:#fff,stroke-width:2px,color:#fff;
    classDef exporter fill:#00a3cc,stroke:#fff,stroke-width:2px,color:#fff;
    classDef source fill:#444,stroke:#fff,stroke-width:1px,color:#fff,stroke-dasharray: 5 5;

    subgraph DataSources ["🔍 Data Sources"]
        LogFiles["Logfiles (Journal, Fail2ban, Docker Socket)"]:::source
        HostOS["Host OS (/proc, /sys, rootfs)"]:::source
        DockerEngine["Docker Engine (Containers, cgroups)"]:::source
        ProxmoxAPI["Proxmox VE (API)"]:::source
    end

    subgraph Agents ["🤖 Agents &amp;amp; Exporters"]
        Promtail["Promtail"]:::exporter
        NodeExporter["Node-Exporter"]:::exporter
        cAdvisor["cAdvisor"]:::exporter
        PVEExporter["PVE-Exporter"]:::exporter
    end

    subgraph Databases ["💾 Storage Databases"]
        Loki["Loki (Log-Aggregation)"]:::loki
        Prometheus["Prometheus (Time-Series Metrics)"]:::prometheus
    end

    Grafana["Grafana (Dashboards)"]:::grafana

    LogFiles -.-&amp;gt; Promtail
    Promtail --&amp;gt; Loki
    HostOS -.-&amp;gt; NodeExporter
    DockerEngine -.-&amp;gt; cAdvisor
    ProxmoxAPI -.-&amp;gt; PVEExporter

    Prometheus --&amp;gt; NodeExporter
    Prometheus --&amp;gt; cAdvisor
    Prometheus --&amp;gt; PVEExporter

    Grafana --&amp;gt; Loki
    Grafana --&amp;gt; Prometheus&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Under Docker the configuration is mostly a copy-paste job. Only the Proxmox exporter needs manual work, since you have to create an API token with the right permissions in the PVE interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dashboards
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://grafana.com/grafana/dashboards/" rel="noopener noreferrer"&gt;grafana.com/dashboards&lt;/a&gt; there's a large selection of community dashboards you can import by ID. My favorites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/grafana/dashboards/1860-node-exporter-full/" rel="noopener noreferrer"&gt;Node Exporter Full&lt;/a&gt;: everything about the host.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/grafana/dashboards/13865-fgc-nginx01-web-analytics/" rel="noopener noreferrer"&gt;Nginx Web Analytics&lt;/a&gt;: who's coming from where?&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/grafana/dashboards/19724-y0nei-s-cadvisor-exporter/" rel="noopener noreferrer"&gt;cAdvisor Exporter&lt;/a&gt;: what are my containers eating?&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/grafana/dashboards/17514-ssh-logs/" rel="noopener noreferrer"&gt;SSH Logs&lt;/a&gt;: who is trying to log in via SSH?&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/grafana/dashboards/21419-crowdsec-metrics/" rel="noopener noreferrer"&gt;CrowdSec Metrics&lt;/a&gt;: who's already been kicked out?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Provisioning runs from YAML files, so everything comes back instantly after a fresh install. The details are in my &lt;a href="https://github.com/SlashGordon/portainer_iac/tree/main/monitoring" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;.&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%2F9wpxvzvq5yad8atcplr6.webp" 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%2F9wpxvzvq5yad8atcplr6.webp" alt="Grafana Dashboard Overview" width="799" height="498"&gt;&lt;/a&gt;&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%2Fwmy8kg8xgv1nutnq5tuw.webp" 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%2Fwmy8kg8xgv1nutnq5tuw.webp" alt="Grafana Dashboard Metrics" width="800" height="538"&gt;&lt;/a&gt;&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%2Fp2l09tz55ecezzn8jilj.webp" 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%2Fp2l09tz55ecezzn8jilj.webp" alt="Grafana Dashboard CrowdSec" width="800" height="480"&gt;&lt;/a&gt;&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%2F8uusgteszymc8xnsvac7.webp" 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%2F8uusgteszymc8xnsvac7.webp" alt="Grafana Dashboard Details" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  First discoveries: hello, botnet
&lt;/h2&gt;

&lt;p&gt;As soon as the first Nginx logs showed up in Grafana, there was a lot more traffic than I expected. A second look showed that most of it came from bots.&lt;/p&gt;

&lt;p&gt;I was getting hammered with 404s for paths like &lt;code&gt;/tool.php&lt;/code&gt;, &lt;code&gt;/admin-footer.php&lt;/code&gt;, or &lt;code&gt;/abc.php&lt;/code&gt;. My sites are static HTML built with Astro, so there is nothing here for a PHP exploit to find, but the bots keep trying every second anyway.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Log excerpt (anonymized):&lt;br&gt;
&lt;code&gt;request for /tool.php with HTTP status: 404 from 172.22.0.8 located in IT&lt;/code&gt;&lt;br&gt;
&lt;code&gt;request for /jga.php with HTTP status: 404 from 172.22.0.8 located in IT&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: Traefik and CrowdSec
&lt;/h2&gt;

&lt;p&gt;I thought Cloudflare's free plan would catch all of this. It doesn't, plenty of these scans still get through. And since Traefik sits behind the Cloudflare proxy, it normally only sees the Cloudflare IP, so a ban would block the proxy itself.&lt;/p&gt;

&lt;p&gt;The answer is CrowdSec, the modern successor to &lt;em&gt;fail2ban&lt;/em&gt;, plus a Traefik plugin. The label &lt;code&gt;forwardedHeadersCustomName=CF-Connecting-IP&lt;/code&gt; tells the CrowdSec plugin to read the real visitor IP from the Cloudflare header.&lt;/p&gt;

&lt;p&gt;Here is my &lt;code&gt;docker-compose.yml&lt;/code&gt; snippet for the two of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;crowdsec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;crowdsecurity/crowdsec:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;crowdsec&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="c1"&gt;# Entrypoint handles the bouncer key from secrets&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;export&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;BOUNCER_KEY_traefik=$(cat&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/run/secrets/crowdsec_bouncer_key)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exec&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/docker_start.sh"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;COLLECTIONS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crowdsecurity/traefik&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;crowdsecurity/http-cve&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;crowdsecurity/base-http-scenarios"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;crowdsec-config:/etc/crowdsec&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;crowdsec-data:/var/lib/crowdsec/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik-access-logs:/var/log/traefik:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;monitoring_net&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;crowdsec_bouncer_key&lt;/span&gt;

  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik:v3.6.6&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;crowdsec&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--accesslog=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--accesslog.filepath=/var/log/traefik/access.log"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--experimental.plugins.crowdsec-bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--experimental.plugins.crowdsec-bouncer.version=v1.4.1"&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.crowdsec.plugin.crowdsec-bouncer.crowdseclapihost=crowdsec:8080"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.crowdsec.plugin.crowdsec-bouncer.forwardedHeadersCustomName=CF-Connecting-IP"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cloudflare&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The middleware in action
&lt;/h3&gt;

&lt;p&gt;From here I just add the middleware label to every web service (portfolio, blog, and so on). Every request gets checked against the CrowdSec database, and if an IP is flagged as malicious, Traefik blocks it immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;blog-astro-web-prd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.blog-astro-prd.rule=Host(`www.slashgordon.link`)"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.blog-astro-prd.middlewares=crowdsec@docker"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Is it worth the effort?
&lt;/h2&gt;

&lt;p&gt;For me, yes. It's reassuring to open Grafana and watch the banned-IPs count climb while the CPU load of my 35-watt roommate stays flat. The dashboard also makes the effect obvious: as soon as a bot starts scanning my &lt;code&gt;.php&lt;/code&gt; paths, it's blocked after a few attempts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://www.slashgordon.link/post/homelab-monitoring-with-grafana/" rel="noopener noreferrer"&gt;www.slashgordon.link&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>docker</category>
      <category>monitoring</category>
      <category>grafana</category>
    </item>
    <item>
      <title>Docker Cleanup and Surgical Data Recovery</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Sat, 12 Sep 2026 17:51:02 +0000</pubDate>
      <link>https://dev.to/cdieck88/the-35-watt-roommate-docker-cleanup-and-surgical-data-recovery-4la1</link>
      <guid>https://dev.to/cdieck88/the-35-watt-roommate-docker-cleanup-and-surgical-data-recovery-4la1</guid>
      <description>&lt;p&gt;Sometimes you just want to clean up a bit and accidentally tear down half the house. That's what happened to me last week with my home lab. I wanted to free up some space on the Lenovo M920q and ran a routine Docker cleanup: delete a few unused images, remove orphaned volumes.&lt;/p&gt;

&lt;p&gt;But when I opened my Gitea instance afterward, the page was blank. No repositories, no users, everything reset to factory defaults. All my code was gone.&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%2Fh3d3mpmtlp3zfyug02op.webp" 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%2Fh3d3mpmtlp3zfyug02op.webp" alt="VM Recovery from Proxmox Backup"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fatal error in the compose file
&lt;/h2&gt;

&lt;p&gt;The answer was in my &lt;code&gt;docker-compose.yml&lt;/code&gt;, where I had made a bad architectural mistake during setup. My volume mapping looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitea&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/volumes/infra_volume/_data/gitea_data:/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance it looks like a normal path. The problem is that &lt;code&gt;/var/lib/docker/volumes/&lt;/code&gt; is Docker's internal storage. I had reached around Docker and pointed a bind mount straight at the raw data of another volume (&lt;code&gt;infra_volume&lt;/code&gt;). Docker didn't see that dependency, so the cleanup job classified the original volume as unused and deleted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surgical recovery from the Proxmox backup
&lt;/h2&gt;

&lt;p&gt;Restoring the entire VM from backup would have taken forever and overwritten the rest of my setup. I only needed one directory out of the backup, so I had to extract the data directly from the Proxmox backup without ever booting the VM.&lt;/p&gt;

&lt;p&gt;The backup lay as a compressed file on my storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vzdump-qemu-100-2026_02_23-01_00_02.vma.zst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, I had to remove the compression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zstd &lt;span class="nt"&gt;-d&lt;/span&gt; vzdump-qemu-100-2026_02_23-01_00_02.vma.zst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I extracted the raw VMA archive using Proxmox's own tool into a temporary folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vma extract vzdump-qemu-100-2026_02_23-01_00_02.vma /mnt/restore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I had a raw image file called &lt;code&gt;tmp-disk-drive-scsi0.raw&lt;/code&gt; in front of me. You can't just mount such an image. It contains its own partitions and, in Ubuntu's case, LVM structures. I had to mount the image as a loop device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;losetup &lt;span class="nt"&gt;-f&lt;/span&gt;
losetup /dev/loop0 /mnt/restore/tmp-disk-drive-scsi0.raw
kpartx &lt;span class="nt"&gt;-av&lt;/span&gt; /dev/loop0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the &lt;code&gt;kpartx&lt;/code&gt; command, I made the partitions in the image visible to the host system. Since Ubuntu uses LVM, I had to wake up the volume group inside the backup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vgscan
vgchange &lt;span class="nt"&gt;-ay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system immediately found the volume group &lt;code&gt;ubuntu-vg&lt;/code&gt;. Now I could finally mount the partition like a normal disk and navigate to my lost data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mount &lt;span class="nt"&gt;-t&lt;/span&gt; ext4 /dev/ubuntu-vg/ubuntu-lv /mnt/vm
&lt;span class="nb"&gt;cd&lt;/span&gt; /mnt/vm/var/lib/docker/volumes/infra_volume/_data/gitea_data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There they were again. My repositories and the Gitea database were intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NFS trap and the SQLite repair
&lt;/h2&gt;

&lt;p&gt;Copying the data back brought the next hurdle. My new destination was an NFS share on my Synology NAS. NFS uses &lt;code&gt;root_squash&lt;/code&gt; by default, so the root user of the Proxmox server can't push files with full permissions to the share, and a normal copy failed.&lt;/p&gt;

&lt;p&gt;The solution was &lt;code&gt;rsync&lt;/code&gt; without taking over permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rsync &lt;span class="nt"&gt;-rlt&lt;/span&gt; &lt;span class="nt"&gt;--no-perms&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-group&lt;/span&gt; /mnt/vm/var/... /mnt/gitea/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relief was short-lived. The file &lt;code&gt;gitea.db&lt;/code&gt; was corrupted, probably because the backup ran at the exact moment the database was being written to. SQLite has a repair mode for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 gitea.db &lt;span class="s2"&gt;".recover"&lt;/span&gt; | sqlite3 gitea_fixed.db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that aborts, you can force SQLite to ignore errors during import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 gitea.db &lt;span class="s2"&gt;".recover"&lt;/span&gt; | sqlite3 &lt;span class="nt"&gt;-init&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;".bail off"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; gitea_fixed.db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I'd do differently next time. I ran &lt;code&gt;.recover&lt;/code&gt; directly on the file sitting on the NFS mount, and SQLite's locking doesn't get along well with NFS, so that's a second corruption waiting to happen. The safer order is: copy the file to local disk, check how bad it actually is, repair it there, then rsync the fixed copy back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 gitea.db &lt;span class="s2"&gt;"PRAGMA integrity_check;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;integrity_check&lt;/code&gt; tells you upfront whether you're looking at a handful of misplaced pages or a completely shredded file, before you commit to a recovery pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new setup
&lt;/h2&gt;

&lt;p&gt;After replacing the database and cleanly unmounting everything (&lt;code&gt;umount&lt;/code&gt;, &lt;code&gt;vgchange -an&lt;/code&gt;, &lt;code&gt;losetup -d&lt;/code&gt;), I rebuilt the setup to avoid the mistake.&lt;/p&gt;

&lt;p&gt;The new compose file keeps the data on the NAS via a direct mount point instead of a path inside Docker's internal storage. Whatever Docker does on the Lenovo, the data stays on the NAS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.9"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitea&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;cloudflare&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2222:2222"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/gitea:/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/api/healthz"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

  &lt;span class="na"&gt;runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/act_runner:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea-runner&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;gitea&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;cloudflare&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;GITEA_RUNNER_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI-Runner&lt;/span&gt;
      &lt;span class="na"&gt;CONFIG_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data/config.yaml&lt;/span&gt;
      &lt;span class="na"&gt;GITEA_INSTANCE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://gitea:3000&lt;/span&gt;
      &lt;span class="na"&gt;GITEA_RUNNER_REGISTRATION_TOKEN_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/run/secrets/gitea_runner_token&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;runner_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cloudflare&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;runner_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitea_runner_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data/compose/secrets/gitea/gitea_runner_token&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I took away from it
&lt;/h2&gt;

&lt;p&gt;Never use &lt;code&gt;/var/lib/docker/volumes/&lt;/code&gt; or any of Docker's internal paths as the source for a bind mount. Use &lt;code&gt;--no-perms --no-owner --no-group&lt;/code&gt; when rsyncing onto an NFS mount. Learn SQLite's &lt;code&gt;.recover&lt;/code&gt; mode before you need it, but run &lt;code&gt;integrity_check&lt;/code&gt; first and repair on local disk, not over NFS. Add a healthcheck so the next failure shows up as a failing container instead of an empty page. And a backup is only worth something once you have actually pulled a single directory out of one and put it back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://www.slashgordon.link/post/homelab-proxmox-vm-recovery/" rel="noopener noreferrer"&gt;www.slashgordon.link&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>docker</category>
      <category>proxmox</category>
      <category>gitea</category>
    </item>
    <item>
      <title>Recovering a lost RNS 510 radio code with a Python script</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Sat, 12 Sep 2026 11:04:50 +0000</pubDate>
      <link>https://dev.to/cdieck88/recovering-a-lost-rns-510-radio-code-with-a-python-script-531i</link>
      <guid>https://dev.to/cdieck88/recovering-a-lost-rns-510-radio-code-with-a-python-script-531i</guid>
      <description>&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%2Fhq9r60lse74oqpg5v6rn.webp" 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%2Fhq9r60lse74oqpg5v6rn.webp" alt="RNS 510" width="500" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I upgraded the stereo in my Volkswagen Tiguan to a newer system, which left the old RNS 510 for sale. Selling it turned out to be less straightforward than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roadblock
&lt;/h2&gt;

&lt;p&gt;The RNS 510 has an anti-theft protection code. When I installed it years ago, it came with a small card holding a four-digit PIN, meant to be kept somewhere safe. I put it somewhere so safe that I couldn't find it when I needed it.&lt;/p&gt;

&lt;p&gt;On the day of the sale, the buyer, a Volkswagen enthusiast, installed the unit in his car and hit the security code prompt. I had no idea where the card was. Without the code the unit is a brick, and the sale was about to fall through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Brute-forcing the PIN
&lt;/h2&gt;

&lt;p&gt;Rather than lose the sale, I looked at brute-forcing the PIN. The unit was mine, so I was recovering my own code.&lt;/p&gt;

&lt;p&gt;Entering thousands of combinations by hand isn't practical, so I wrote a tool to do it. That became the RNS510 Code Finder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the RNS510 Code Finder
&lt;/h2&gt;

&lt;p&gt;The script is Python and uses &lt;code&gt;pyserial&lt;/code&gt;. It connects to the RNS 510 over a serial (COM) port, sends each PIN in a given range, reads the device's response to see whether the PIN was accepted, and logs every attempt. I've since cleaned it up and released it as open source.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;If you're in the same spot, trying to sell an RNS 510 without the PIN card, here's how to run the RNS510 Code Finder.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Clone the repository
&lt;/h3&gt;

&lt;p&gt;First, clone the RNS510 Code Finder repository from GitHub:&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 https://github.com/SlashGordon/rns510-code-finder.git
&lt;span class="nb"&gt;cd &lt;/span&gt;rns510-code-finder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Install pyserial
&lt;/h3&gt;

&lt;p&gt;With Python installed, add the &lt;code&gt;pyserial&lt;/code&gt; library:&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;pyserial
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Run the script
&lt;/h3&gt;

&lt;p&gt;Connect the RNS 510 to your computer via a serial port and run the script. This example uses &lt;code&gt;COM1&lt;/code&gt;; replace it with the correct COM port for your device. You can also adjust &lt;code&gt;--baudrate&lt;/code&gt;, &lt;code&gt;--timeout&lt;/code&gt;, &lt;code&gt;--start&lt;/code&gt;, and &lt;code&gt;--stop&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python rns510_code_finder.py &lt;span class="nt"&gt;--portname&lt;/span&gt; COM1 &lt;span class="nt"&gt;--baudrate&lt;/span&gt; 9600 &lt;span class="nt"&gt;--timeout&lt;/span&gt; 2 &lt;span class="nt"&gt;--start&lt;/span&gt; 0 &lt;span class="nt"&gt;--stop&lt;/span&gt; 1999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script tests PINs across the range and prints the correct code to the terminal when it hits it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Example output
&lt;/h3&gt;

&lt;p&gt;Running it looks roughly like this:&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="nv"&gt;$ &lt;/span&gt;python rns510_code_finder.py &lt;span class="nt"&gt;--portname&lt;/span&gt; COM1 &lt;span class="nt"&gt;--baudrate&lt;/span&gt; 115200 &lt;span class="nt"&gt;--timeout&lt;/span&gt; 1 &lt;span class="nt"&gt;--start&lt;/span&gt; 0 &lt;span class="nt"&gt;--stop&lt;/span&gt; 1999
2023-08-12 14:23:45,123 - INFO - Opened serial port COM1
2023-08-12 14:23:45,124 - INFO - Trying code: 0000
2023-08-12 14:23:45,225 - INFO - Code 0000 is invalid.
...
2023-08-12 14:24:15,567 - INFO - Trying code: 1234
2023-08-12 14:24:15,668 - INFO - Code found: 1234
2023-08-12 14:24:15,669 - INFO - Closed serial port COM1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code it prints unlocks the RNS 510.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it went
&lt;/h2&gt;

&lt;p&gt;I connected my PC to the RNS 510, started the script, and watched it work through the PINs. A few minutes later it found the code, and the unit powered up.&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%2Fexjn6yrp2jmsoo9p2quq.webp" 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%2Fexjn6yrp2jmsoo9p2quq.webp" alt="RNS 510 Code Finder result" width="500" height="377"&gt;&lt;/a&gt;&lt;br&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%2Fhy6evzgxs8kgq56rolxr.webp" 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%2Fhy6evzgxs8kgq56rolxr.webp" alt="RNS 510 successfully unlocked" width="500" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote the code down, gave it to the buyer, and the sale went through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Keep your radio code somewhere you'll find it again. If you've already lost it, the RNS510 Code Finder is on GitHub. Use it only on a device you own.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/SlashGordon/rns510-code-finder" rel="noopener noreferrer"&gt;RNS510 Code Finder&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT License - Open for everyone to use, improve, and share.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://www.slashgordon.link/post/rns510_code_finder/" rel="noopener noreferrer"&gt;www.slashgordon.link&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>vw</category>
      <category>rns510</category>
      <category>code</category>
    </item>
    <item>
      <title>astro-gallery: four image gallery components for Astro</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:42:51 +0000</pubDate>
      <link>https://dev.to/cdieck88/astro-gallery-four-image-gallery-components-for-astro-4kik</link>
      <guid>https://dev.to/cdieck88/astro-gallery-four-image-gallery-components-for-astro-4kik</guid>
      <description>&lt;p&gt;Every time I write a post with photos in it, I need a gallery. The dog post needed one. The paving post needed three. I kept copying the same Astro component from post to post, tweaking it slightly each time, and the copies slowly drifted apart.&lt;/p&gt;

&lt;p&gt;So I did the sensible thing and turned it into a package. It is called &lt;code&gt;astro-gallery&lt;/code&gt;, it is on npm, and my blog now runs on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What astro-gallery is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;astro-gallery&lt;/code&gt; is a set of four gallery components for &lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt;. You point a component at a folder in &lt;code&gt;src/&lt;/code&gt;, drop your photos in, and it handles the rest: image optimisation through &lt;code&gt;astro:assets&lt;/code&gt;, responsive &lt;code&gt;srcset&lt;/code&gt;, a lightbox, EXIF reading at build time, and structured data. No client framework, no config file, one integration.&lt;/p&gt;

&lt;p&gt;The four components:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JustifiedGallery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Aspect-ratio-aware rows that fill the width edge to edge. Nothing gets cropped to a grid cell.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ImageGallery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A uniform responsive grid. Click a thumbnail, get a lightbox with arrow-key navigation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ImageTimeline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads each photo's EXIF capture date, groups by day, lays the days out on a timeline. Horizontal rail or vertical spine.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MapGallery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads GPS EXIF and drops a photo marker per location on a Leaflet map. Ships a consent gate so no tile request happens before the visitor agrees.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The photos in the examples below are real. A hiking trip in the Harz, a few days on Rügen, and some odds and ends from around Hamburg. All of them live in one folder, &lt;code&gt;src/assets/images/demo/&lt;/code&gt;, and every example reads from that same folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Install the package and add the integration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;astro-gallery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// astro.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;gallery&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-gallery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;gallery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// pick a basemap for MapGallery. 'osm' is keyless.&lt;/span&gt;
      &lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;basemap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;osm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The integration exposes its resolved config to the components through a virtual module. The components will not render without it, so this step is not optional.&lt;/p&gt;

&lt;p&gt;Then import a component in any &lt;code&gt;.astro&lt;/code&gt; or &lt;code&gt;.mdx&lt;/code&gt; file and give it a folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import JustifiedGallery from 'astro-gallery/components/JustifiedGallery.astro';
---
&amp;lt;JustifiedGallery folderPath="demo" album="Trips 2025 and 2026" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  JustifiedGallery
&lt;/h2&gt;

&lt;p&gt;This is the one I reach for first. Rows are justified to the container width using each photo's aspect ratio, so a landscape shot takes more horizontal space than a portrait, and nothing is cropped to fit a box. It is the layout you know from Flickr, Unsplash and the Lightroom web galleries.&lt;/p&gt;

&lt;p&gt;It is built for Core Web Vitals. Every image carries its intrinsic &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; so there is zero layout shift, offscreen rows use &lt;code&gt;content-visibility&lt;/code&gt; to skip rendering work, and the first couple of images load eagerly with &lt;code&gt;fetchpriority="high"&lt;/code&gt;. The rest lazy load. There is a blur-up skeleton while each image decodes.&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%2Fosbdui0abwuma0g8vl8o.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%2Fosbdui0abwuma0g8vl8o.png" alt="JustifiedGallery rendered with the demo trip photos. Aspect-ratio-justified rows roughly 240px tall, landscape and portrait shots sitting side by side, every row filling the full content width with no cropping." width="800" height="730"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;alt&lt;/code&gt; prop is a map of file name to alt text. You only fill in the ones that matter. For the rest, the component builds an alt string from the file name or from an embedded IPTC caption if the photo has one. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  ImageGallery
&lt;/h2&gt;

&lt;p&gt;Same photos, uniform grid. Every cell is identical, so each image is cropped with &lt;code&gt;object-fit: cover&lt;/code&gt; to fill it. Use this when your photos are roughly the same shape, or when the gallery is really just navigation and you want it tidy.&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%2Fzieqndd95iycg8lr44nc.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%2Fzieqndd95iycg8lr44nc.png" alt="ImageGallery with the same photos in a uniform 4-column grid. Every cell the same size, images cropped square with object-fit cover." width="800" height="744"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It also takes an explicit &lt;code&gt;images&lt;/code&gt; array if you would rather list imported images, &lt;code&gt;/public&lt;/code&gt; paths or remote URLs by hand, with per-image &lt;code&gt;alt&lt;/code&gt; and &lt;code&gt;caption&lt;/code&gt;. That is the mode the older posts on my blog use.&lt;/p&gt;

&lt;h2&gt;
  
  
  ImageTimeline
&lt;/h2&gt;

&lt;p&gt;Here is where the EXIF reading earns its keep. &lt;code&gt;ImageTimeline&lt;/code&gt; reads &lt;code&gt;DateTimeOriginal&lt;/code&gt; from each photo, groups the photos by calendar day, and lays the days out in order. If a day has GPS data it reverse-geocodes a location label for that group, once, at build time. Nothing hits the network in the browser.&lt;/p&gt;

&lt;p&gt;Default orientation is a horizontal rail you scroll sideways:&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%2F60fm8o5ced5f6jwbqggb.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%2F60fm8o5ced5f6jwbqggb.png" alt="ImageTimeline horizontal rail. Photos grouped into day columns along a scrollable timeline, each group with a date heading and a reverse-geocoded location label above its thumbnails." width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;orientation="vertical"&lt;/code&gt; and the same data stacks down the page on a left-hand spine:&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%2F7woddotcif9aowbds50w.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%2F7woddotcif9aowbds50w.png" alt="ImageTimeline vertical variant. The same day groups stacked down the page against a left-hand spine, dates sitting on the spine." width="800" height="1558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Rügen days cluster together in August 2025. The Harz days sit in June 2026. You can see the shape of both trips without a caption telling you.&lt;/p&gt;

&lt;h2&gt;
  
  
  MapGallery
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MapGallery&lt;/code&gt; reads the GPS coordinates, builds one circular photo marker per location, and puts them on a Leaflet map. Photos without GPS are skipped. If none of the photos have GPS, the component renders nothing.&lt;/p&gt;

&lt;p&gt;The important part is the consent gate. Map tiles come from a third-party CDN, and loading them sends the visitor's IP address to that provider. So &lt;code&gt;MapGallery&lt;/code&gt; renders an overlay first and loads nothing from the network until the visitor clicks the button. The choice is remembered in &lt;code&gt;localStorage&lt;/code&gt;.&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%2Fcgep1wvufu4grmdmwo7x.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%2Fcgep1wvufu4grmdmwo7x.png" alt="MapGallery. Left: the consent overlay covering the map with a short privacy note and a load button. Right: after consent, a Leaflet map with circular photo markers at each GPS location from the demo set." width="799" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Underneath the map, in the HTML, there is a plain list of linked thumbnails with real &lt;code&gt;alt&lt;/code&gt; text. Search engines and no-JS visitors get that list. The client script swaps in the interactive map once you accept the gate. Nobody loses the content.&lt;/p&gt;

&lt;p&gt;You pick the look with a &lt;code&gt;basemap&lt;/code&gt; preset: &lt;code&gt;osm&lt;/code&gt; (keyless), &lt;code&gt;carto-dark&lt;/code&gt;, &lt;code&gt;carto-light&lt;/code&gt;, &lt;code&gt;carto-voyager&lt;/code&gt;, &lt;code&gt;stadia-dark&lt;/code&gt; or &lt;code&gt;esri-satellite&lt;/code&gt;. The CARTO and Stadia presets need a free API key, which you pass as &lt;code&gt;map.tileApiKey&lt;/code&gt;. On my blog the key lives in a Gitea secret and the config falls back to plain OpenStreetMap when it is not set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get for free
&lt;/h2&gt;

&lt;p&gt;The reason I bothered to package this properly, rather than keep copy-pasting, is the boring stuff. Every component renders crawlable, accessible markup on the server. The JavaScript only enhances it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic HTML.&lt;/strong&gt; Galleries are a &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt; of &lt;code&gt;&amp;lt;figure&amp;gt;&lt;/code&gt; elements. Each thumbnail is a real &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; to the full-size image, so crawlers can follow it. The timeline is a &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; with an &lt;code&gt;&amp;lt;ol&amp;gt;&lt;/code&gt; where each day heading is a &lt;code&gt;&amp;lt;time datetime="..."&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alt text that is not a file name.&lt;/strong&gt; Resolution order: an explicit value you pass, then an embedded IPTC or XMP caption, then a humanised file name (&lt;code&gt;IMG_4821.JPG&lt;/code&gt; is recognised as camera noise and ignored), then &lt;code&gt;"&amp;lt;album&amp;gt; photo N"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy loading without layout shift.&lt;/strong&gt; Intrinsic &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on every &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, plus &lt;code&gt;loading="lazy"&lt;/code&gt; and &lt;code&gt;decoding="async"&lt;/code&gt;. The first image is eager and high priority for LCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive images.&lt;/strong&gt; &lt;code&gt;ImageGallery&lt;/code&gt; and &lt;code&gt;JustifiedGallery&lt;/code&gt; emit a &lt;code&gt;srcset&lt;/code&gt; across a set of widths, never upscaling past the source, with a matching &lt;code&gt;sizes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured data.&lt;/strong&gt; Each gallery emits one &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; with an &lt;code&gt;ImageGallery&lt;/code&gt; whose &lt;code&gt;associatedMedia&lt;/code&gt; is a list of &lt;code&gt;ImageObject&lt;/code&gt; entries. Relative URLs become absolute when &lt;code&gt;site&lt;/code&gt; is set in &lt;code&gt;astro.config&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  astro-gallery vs rolling your own
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;astro-gallery&lt;/th&gt;
&lt;th&gt;Hand-rolled component&lt;/th&gt;
&lt;th&gt;A JS lightbox library&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Image optimisation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;astro:assets&lt;/code&gt;, built in&lt;/td&gt;
&lt;td&gt;You wire up &lt;code&gt;getImage&lt;/code&gt; yourself&lt;/td&gt;
&lt;td&gt;Usually none, you pass URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EXIF date and GPS&lt;/td&gt;
&lt;td&gt;Built in, build time&lt;/td&gt;
&lt;td&gt;You add &lt;code&gt;exifr&lt;/code&gt; and the plumbing&lt;/td&gt;
&lt;td&gt;Out of scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consent gate for map tiles&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;You build it&lt;/td&gt;
&lt;td&gt;Out of scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic HTML and JSON-LD&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Whatever you remember to add&lt;/td&gt;
&lt;td&gt;Depends on the library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client JS&lt;/td&gt;
&lt;td&gt;About 2 kB for the lightbox, Leaflet only on map pages&lt;/td&gt;
&lt;td&gt;Your call&lt;/td&gt;
&lt;td&gt;The whole library on every page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config surface&lt;/td&gt;
&lt;td&gt;One integration, sensible defaults&lt;/td&gt;
&lt;td&gt;Full control, full maintenance&lt;/td&gt;
&lt;td&gt;Its own API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rolling your own is fine. I did it for a year. The problem was never the first copy. It was the fifth copy, slightly different from the other four, with a bug I had already fixed somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does it need a client framework like React?&lt;/strong&gt;&lt;br&gt;
No. The components are &lt;code&gt;.astro&lt;/code&gt; files. The only client JavaScript is a small dependency-free lightbox, plus Leaflet on pages that use &lt;code&gt;MapGallery&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do the images live?&lt;/strong&gt;&lt;br&gt;
Anywhere under &lt;code&gt;src/&lt;/code&gt;. The default base folder is &lt;code&gt;src/assets/images/&lt;/code&gt;, so &lt;code&gt;folderPath="demo"&lt;/code&gt; reads from &lt;code&gt;src/assets/images/demo/&lt;/code&gt;. &lt;code&gt;ImageGallery&lt;/code&gt; also accepts imported images and remote URLs directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the reverse geocoding call an API on every build?&lt;/strong&gt;&lt;br&gt;
Only for coordinates it has not seen before. Results are cached to a JSON file that you commit, shared by &lt;code&gt;ImageTimeline&lt;/code&gt; and &lt;code&gt;MapGallery&lt;/code&gt;. After the first run, builds and CI never touch the network for geocoding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I theme it?&lt;/strong&gt;&lt;br&gt;
Yes. Everything is namespaced under &lt;code&gt;.asg-*&lt;/code&gt; and driven by CSS custom properties. Override the tokens in your own CSS. Light and dark are picked up from &lt;code&gt;prefers-color-scheme&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which Astro versions work?&lt;/strong&gt;&lt;br&gt;
Astro 4 and up. My blog runs Astro 7.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/astro-gallery" rel="noopener noreferrer"&gt;&lt;code&gt;astro-gallery&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source and full docs: &lt;a href="https://github.com/SlashGordon/astro-gallery" rel="noopener noreferrer"&gt;github.com/SlashGordon/astro-gallery&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>astro</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Monte Carlo Simulation: How to go broke eight times faster with 8 Eurojackpot lines</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Sun, 30 Aug 2026 18:24:35 +0000</pubDate>
      <link>https://dev.to/cdieck88/monte-carlo-simulation-how-to-go-broke-eight-times-faster-with-8-eurojackpot-lines-4kn1</link>
      <guid>https://dev.to/cdieck88/monte-carlo-simulation-how-to-go-broke-eight-times-faster-with-8-eurojackpot-lines-4kn1</guid>
      <description>&lt;p&gt;Every lottery player knows the saying: &lt;em&gt;"One line is no line, you have to play a few more to boost your chances!"&lt;/em&gt; That's why the average player often fills out a complete ticket with 8 lines. &lt;/p&gt;

&lt;p&gt;Sounds like a solid strategy, right? Wrong. It’s actually the fastest way to systematically burn through your cash. &lt;/p&gt;

&lt;p&gt;To prove it, we wrote a Python simulation that uses historical payout data and cold, hard combinatorics to see who actually has any money left in their account at the end.&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%2Fb0vdo8raxjbzvyjhyvub.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%2Fb0vdo8raxjbzvyjhyvub.png" alt="Data visualization of a Monte Carlo simulation using a symmetric log scale. A dense cluster of red lines (losses) drops steeply downwards, while isolated outliers in blue and gold mark fleeting wins or jackpots. The chart highlights the optical illusion of rare moments of luck against the backdrop of statistical ruin." width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What exactly is a Monte Carlo simulation?
&lt;/h2&gt;

&lt;p&gt;Named after the famous casino in Monaco, the Monte Carlo simulation is basically the brute-force approach to probability theory. &lt;/p&gt;

&lt;p&gt;Usually, mathematicians use a single, elegant formula to calculate the expected value. That formula will dryly inform you: &lt;em&gt;"You lose an average of 1 Euro per Eurojackpot line."&lt;/em&gt; That might be mathematically correct, but emotionally, it's a bit of a snooze. It doesn't capture the true pain of slowly bleeding out financially.&lt;/p&gt;

&lt;p&gt;The Monte Carlo simulation throws that elegant formula right out the window. Its core concept is pure, raw computing power. Instead of just calculating the theoretical outcome, we let the computer simply &lt;em&gt;play through&lt;/em&gt; reality thousands of times. It’s not an equation; it’s a &lt;strong&gt;simulation and iteration&lt;/strong&gt; of real events. &lt;/p&gt;

&lt;p&gt;The computer spawns 1,000 fictional players. For every player and every draw over the last 10 years, it generates a random number based on the actual Eurojackpot probabilities. It simulates the real-world winning and (mostly) losing, step by step. At the end, we aren't looking at some abstract theoretical number, but at the very real, blood-red bank accounts of 1,000 ruined clones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We're using &lt;strong&gt;Polars&lt;/strong&gt; for lightning-fast data processing, &lt;strong&gt;NumPy&lt;/strong&gt; to simulate millions of random draws, and &lt;strong&gt;Matplotlib&lt;/strong&gt; to visualize our financial doom.&lt;/p&gt;

&lt;p&gt;First, grab &lt;a href="///downloads/eurojackpot-history.json"&gt;our historical Eurojackpot database&lt;/a&gt; and drop it into the same folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Script
&lt;/h2&gt;

&lt;p&gt;Here is the complete Python code. It calculates the exact mathematical odds on the fly using combinatorics (&lt;code&gt;math.comb&lt;/code&gt;), links them up with the real average payouts from recent years, and then runs 1,000 players through the brute-force meat grinder.&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Eurojackpot Monte Carlo Simulation
===================================
Simulates realistic lottery participation over multiple years
using exact combinatorial probabilities and historical payout data.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TYPE_CHECKING&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib.pyplot&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib.ticker&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;polars&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TYPE_CHECKING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basicConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%(message)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="n"&gt;MAIN_POOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="n"&gt;MAIN_PICK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="n"&gt;EURO_POOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
&lt;span class="n"&gt;EURO_PICK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;TICKET_COST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;
&lt;span class="n"&gt;DRAWS_PER_YEAR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;104&lt;/span&gt;  &lt;span class="c1"&gt;# 2× per week
&lt;/span&gt;
&lt;span class="n"&gt;PRIZE_TIERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&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;5 + 2&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 + 1&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 + 0&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4 + 2&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4 + 1&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4 + 0&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 + 2&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2 + 2&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 + 1&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 + 0&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1 + 2&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2 + 1&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Config dataclass
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SimConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;players&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1_000&lt;/span&gt;
    &lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="n"&gt;fields_per_draw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
    &lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;total_draws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;years&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;DRAWS_PER_YEAR&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cost_per_draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields_per_draw&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;TICKET_COST&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;total_spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_draws&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_per_draw&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Probability calculation
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_exact_probabilities&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Exact Eurojackpot winning probabilities via combinatorics.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;total_main&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAIN_POOL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAIN_PICK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;total_euro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EURO_POOL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EURO_PICK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;prob_sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match_m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match_e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PRIZE_TIERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;ways_main&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAIN_PICK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match_m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;MAIN_POOL&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;MAIN_PICK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAIN_PICK&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;match_m&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ways_euro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EURO_PICK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match_e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;EURO_POOL&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;EURO_PICK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EURO_PICK&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;match_e&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;prob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ways_main&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_main&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ways_euro&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_euro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prob&lt;/span&gt;
        &lt;span class="n"&gt;prob_sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;prob&lt;/span&gt;

    &lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No Win&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prob_sum&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;probabilities&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_empirical_payouts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&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;ndarray&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;ndarray&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Load historical payout data and align with probability tiers.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[1/3] Loading historical payouts…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_json&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;file_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;payouts_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prizeDistribution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;unnest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prizeDistribution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;avg_payouts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payouts_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;to_dicts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;tiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;payouts&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No Win&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;avg_payouts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;0.0&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;dtype&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="n"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;probs&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;fromiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dtype&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="n"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;probs&lt;/span&gt; &lt;span class="o"&gt;/=&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# normalise to guard against floating-point drift
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payouts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Monte Carlo simulation
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_monte_carlo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;payouts&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;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;probs&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;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SimConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Vectorised Monte Carlo simulation.

    Memory layout: (total_draws, players, fields_per_draw)
    All draws are sampled in a single call for maximum throughput.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rng&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="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[2/3] Simulating %d players over %d draws…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;players&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_draws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;      Fields/draw: %d  |  Cost/draw: €%.2f  |  Total per player: €%.2f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields_per_draw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_per_draw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_spent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# --- sample outcomes for every (draw, player, field) triple at once ---
&lt;/span&gt;    &lt;span class="n"&gt;outcome_indices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payouts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_draws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;players&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields_per_draw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;raw_winnings&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;ndarray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payouts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;outcome_indices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# sum over fields, subtract per-draw cost, cumsum over time
&lt;/span&gt;    &lt;span class="n"&gt;net_per_draw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw_winnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_per_draw&lt;/span&gt;  &lt;span class="c1"&gt;# (draws, players)
&lt;/span&gt;    &lt;span class="n"&gt;bankroll&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;cumsum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;net_per_draw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# (draws, players)
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;bankroll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&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="s"&gt;Player_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;players&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Visualisation helpers
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt; &lt;span class="o"&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;jackpot&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;#00E5FF&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;major&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;#FFD700&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;minor&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;#FF9800&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;profit&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;#00ff88&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;loss&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;#ff4d4d&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;worst&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;#ffffff&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;breakeven&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;#00bfff&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;bg_fig&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;#0f1117&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;bg_ax&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;#111827&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;bg_legend&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;#1f2937&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;bg_text&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;#1f2937&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;edge_text&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;#ff4d4d&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_profit_color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jackpot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;major&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;profit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_apply_dark_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Figure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Axes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_facecolor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bg_fig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_facecolor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bg_ax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_add_stats_box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Axes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;winners&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;players&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.04&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;Stake per draw: €&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;TICKET_COST&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&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;Average result: €&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;avg&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&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;Profitable players: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;winners&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;players&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="n"&gt;transform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transAxes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;boxstyle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;round,pad=0.6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;facecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bg_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;edgecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edge_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_euro_formatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_pos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;€ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Plot generation
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;analyze_and_plot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SimConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate log-scale (illusion) and linear-scale (reality) charts.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[3/3] Creating visualisations…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;players&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&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;final_balances&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;avg_balance&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final_balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;best_balance&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final_balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;worst_balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final_balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;best_idx&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final_balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;worst_idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final_balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmin&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;best_col&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&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;best_idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;worst_col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&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;worst_idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;winners_mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;final_balances&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;winners_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winners_mask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;winner_cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&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;i&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winners_mask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;df&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;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;best_col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;loser_cols&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&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;i&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winners_mask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# cap losers shown to 500 for readability
&lt;/span&gt;    &lt;span class="n"&gt;sample_losers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loser_cols&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dark_background&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;chart_configs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&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;eurojackpot_1_illusion_log.png&lt;/span&gt;&lt;span class="sh"&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="s"&gt;The Illusion: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Years Eurojackpot (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields_per_draw&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Fields)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&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;eurojackpot_2_reality_linear.png&lt;/span&gt;&lt;span class="sh"&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="s"&gt;The Reality: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Years Eurojackpot (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields_per_draw&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Fields)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5_000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&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;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;use_log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ylim_max&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chart_configs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subplots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;figsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;_apply_dark_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# losers (faint red)
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sample_losers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_numpy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zorder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# non-best winners
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;winner_cols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;series&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_numpy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;series&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_profit_color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;series&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
                    &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zorder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# best &amp;amp; worst highlighted
&lt;/span&gt;        &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;best_col&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_numpy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_profit_color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best_balance&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&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;Best: €&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best_balance&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&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="n"&gt;zorder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;worst_col&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_numpy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worst&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;linestyle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&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;Worst: €&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;worst_balance&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&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="n"&gt;zorder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;axhline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;breakeven&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;linestyle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Breakeven (€0)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;use_log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_yscale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;symlog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linthresh&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net Bankroll (Log Scale)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worst_balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ylim_max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net Bankroll (Linear Scale)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_xlabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Number of Draws (Tuesday &amp;amp; Friday)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linestyle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yaxis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_major_formatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FuncFormatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_euro_formatter&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="n"&gt;legend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;legend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upper right&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;legend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_frame&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;set_facecolor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bg_legend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;legend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_frame&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;set_edgecolor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#374151&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;_add_stats_box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avg_balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;winners_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;players&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields_per_draw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tight_layout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;out_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_dir&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;
        &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;savefig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bbox_inches&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[+] Saved: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;All charts generated successfully.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data_file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eurojackpot-history.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;data_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Error: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; not found.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SimConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;players&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;years&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="n"&gt;fields_per_draw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;probabilities&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_exact_probabilities&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;payouts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_empirical_payouts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;df_simulation&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_monte_carlo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payouts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;analyze_and_plot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_simulation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;When you run the script, something visually striking happens. Thanks to the &lt;code&gt;symlog&lt;/code&gt; scaling on the Y-axis, we get to see both: the brutal, steady losses of the general public &lt;em&gt;and&lt;/em&gt; the absurd outliers of the extreme winners, all without breaking the chart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hard truth in numbers:&lt;/strong&gt;&lt;br&gt;
At 8 lines per draw, you will burn through roughly &lt;strong&gt;€11,500&lt;/strong&gt; in 10 years. The simulation shows it clearly: even if you hit 3 correct numbers now and then, it won't save you from the statistical expected value. The red cloud inevitably drags every player into the negative. Most of the time, not a single one of the 1,000 simulated players finishes the decade in the green.&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%2F7s1jyj6damztl2nlklm6.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%2F7s1jyj6damztl2nlklm6.png" alt="Linear line chart showing bankroll development over 10 years. Almost all 1,000 paths follow a steady, negative slope straight into a five-figure loss. A lone text box highlights the average final result of roughly -11,500 Euros—a visual confirmation of the law of large numbers." width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Think you're that green outlier on the chart?&lt;/strong&gt; Test your own 8 lucky lines in our &lt;a href="https://www.google.com/search?q=https://www.howtolosemoneyfast.com/en/eurojackpot/" rel="noopener noreferrer"&gt;Eurojackpot Simulator&lt;/a&gt; and click your way into bankruptcy!&lt;/p&gt;

</description>
      <category>lottery</category>
      <category>montecarlo</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>10 Years of Eurojackpot Data with Polars</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Sat, 29 Aug 2026 17:57:24 +0000</pubDate>
      <link>https://dev.to/cdieck88/10-years-of-eurojackpot-data-with-polars-2e8p</link>
      <guid>https://dev.to/cdieck88/10-years-of-eurojackpot-data-with-polars-2e8p</guid>
      <description>&lt;p&gt;Welcome to the intersection of high-performance data engineering and terrible financial decisions. &lt;/p&gt;

&lt;p&gt;In this post, we are going to do what every hopeful gambler eventually tries: analyze historical lottery data to find the "secret pattern." To do this, we will use Python's &lt;strong&gt;Polars&lt;/strong&gt; library—because if we are going to calculate our inevitable financial ruin, we might as well do it with blazing fast, multi-threaded performance.&lt;/p&gt;

&lt;p&gt;Spoiler alert: the data won't help you win. But it is a fantastic exercise in data manipulation, nested JSON parsing, and visualization. Let's dive in.&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%2F5dxpvdcdz8cznxwptyyq.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%2F5dxpvdcdz8cznxwptyyq.png" alt="Eurojackpot reality check chart showing number frequencies and payout statistics" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Setup Your Development Environment
&lt;/h2&gt;

&lt;p&gt;To follow along, you will need a Python environment with Polars (for data crunching) and Matplotlib (to draw pretty charts of our losses). Run this in your terminal:&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="nb"&gt;mkdir &lt;/span&gt;eurojackpot-analysis
&lt;span class="nb"&gt;cd &lt;/span&gt;eurojackpot-analysis
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;polars matplotlib

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Loading the Eurojackpot Draw History
&lt;/h2&gt;

&lt;p&gt;We provide a massive, free dataset of all Eurojackpot draws over the last 10+ years right here on the site. Let's grab it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget https://www.howtolosemoneyfast.com/downloads/eurojackpot-history.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: The "Reality Check" Script
&lt;/h2&gt;

&lt;p&gt;Here is the complete Python script. It uses Polars' powerful &lt;code&gt;.explode()&lt;/code&gt; method to untangle the nested arrays of winning numbers, and &lt;code&gt;.unnest()&lt;/code&gt; to flatten the prize distribution data.&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;polars&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib.pyplot&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Union&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_lottery_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Union&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Loads historical lottery data from a JSON file.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;FileNotFoundError&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;Dataset not found at: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_json&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;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_number_frequencies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculates the frequency of drawn numbers.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency&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;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;descending&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;analyze_payout_distribution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculates summary statistics for minor prize tiers.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payouts_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&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="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prizeDistribution&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;unnest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prizeDistribution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;summary_stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payouts_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2 + 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drop_nulls&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2 Main + 1 Euro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 + 0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drop_nulls&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 Main + 0 Euro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 + 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drop_nulls&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3 Main + 1 Euro&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;summary_stats&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;plot_reality_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top_main&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payout_stats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generates a professional dual-plot visualization.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="c1"&gt;# Set up a professional plotting style
&lt;/span&gt;    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ggplot&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subplots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;figsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suptitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Eurojackpot Historical Analysis: The Illusion of Strategy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# --- Plot 1: "Hot" Numbers ---
&lt;/span&gt;    &lt;span class="c1"&gt;# Convert Polars columns to lists for Matplotlib
&lt;/span&gt;    &lt;span class="n"&gt;numbers&lt;/span&gt; &lt;span class="o"&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;n&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;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top_main&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mainNumbers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_list&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="n"&gt;frequencies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;top_main&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frequencies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#2c3e50&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edgecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;black&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Top 10 Most Frequent Main Numbers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_xlabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lottery Number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Times Drawn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Add a cynical watermark/text box
&lt;/span&gt;    &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Past performance does not&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;indicate future results.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
             &lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transAxes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&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="n"&gt;verticalalignment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;top&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
             &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;boxstyle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;round&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;facecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;white&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# --- Plot 2: Payout Reality ---
&lt;/span&gt;    &lt;span class="c1"&gt;# Extract column names and their single row values
&lt;/span&gt;    &lt;span class="n"&gt;tiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payout_stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
    &lt;span class="n"&gt;avg_payouts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payout_stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avg_payouts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#e74c3c&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edgecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;black&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Average Payouts for Common &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Wins&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_xlabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Matching Tiers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Average Payout (€)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Add exact values on top of the bars
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;avg_payouts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&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;€&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&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="n"&gt;ha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;center&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tight_layout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Save the plot so you can upload it straight to your blog
&lt;/span&gt;    &lt;span class="n"&gt;output_filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eurojackpot_reality_check.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;savefig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&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="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[+] Chart successfully generated and saved as &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;output_filename&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="c1"&gt;# Show the plot window
&lt;/span&gt;    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tbl_formatting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ASCII_MARKDOWN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tbl_hide_column_data_types&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;file_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eurojackpot-history.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Initializing Eurojackpot Historical Analysis...&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_lottery_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Get top 10 for a better looking chart
&lt;/span&gt;        &lt;span class="n"&gt;top_main&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_number_frequencies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mainNumbers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_n&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="n"&gt;payout_stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;analyze_payout_distribution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Generate the visualization
&lt;/span&gt;        &lt;span class="nf"&gt;plot_reality_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top_main&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payout_stats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&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;Analysis failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;When you run this script, Matplotlib generates the following reality check:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The takeaway:&lt;/strong&gt; Even if you successfully pick the "hottest" historical numbers, and you manage to match 3 Main numbers and 1 Euro number... congratulations. You've statistically won enough to buy a nice pizza.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Want to see the math in action without writing any code?&lt;/strong&gt; Try running 1,000 lifetimes of tickets through our live &lt;a href="https://www.google.com/search?q=https://www.howtolosemoneyfast.com/eurojackpot/" rel="noopener noreferrer"&gt;Eurojackpot Simulator&lt;/a&gt; and watch your virtual bank account drain in real-time.&lt;/p&gt;

</description>
      <category>python</category>
      <category>polars</category>
      <category>lottery</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Middle-earth and the Digital Infarction</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Fri, 21 Aug 2026 09:15:16 +0000</pubDate>
      <link>https://dev.to/cdieck88/middle-earth-and-the-digital-infarction-1hel</link>
      <guid>https://dev.to/cdieck88/middle-earth-and-the-digital-infarction-1hel</guid>
      <description>&lt;p&gt;It was supposed to be a relaxed evening. I had finally convinced my girlfriend to watch the Lord of the Rings Extended Edition with me. The mood was good, the elves were marching into Helm's Deep. But right around 10 PM, everything went dark.&lt;/p&gt;

&lt;p&gt;The entire LAN was dead. No streaming on the TV and no internet on the PC. The strange thing was that the Wi-Fi on my Fritzbox 7690 was still working perfectly. Only everything connected via cable was completely knocked out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Suspect in the Server Closet
&lt;/h2&gt;

&lt;p&gt;Since I had just recently put the Lenovo M920q into service, it was naturally my prime suspect. My first assumption was a misconfigured Docker container. I thought about problems with &lt;code&gt;macvlan&lt;/code&gt; or a container in Network Host Mode that might be causing a loop.&lt;/p&gt;

&lt;p&gt;I disabled a suspicious container and had two days of peace. I thought the problem was solved. But then it happened again: right in the middle of the movie, the LAN was gone. Only when I pulled the network cable from the Lenovo did the entire network calm down immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hunting Through the Kernel Logs
&lt;/h2&gt;

&lt;p&gt;I SSHed into the Proxmox host and searched through the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dmesg &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; e1000e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There I found error messages appearing every second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;e1000e 0000:00:1f.6 nic0: Detected Hardware Unit Hang
TDH                  &amp;lt;52&amp;gt;
TDT                  &amp;lt;73&amp;gt;
next_to_use          &amp;lt;73&amp;gt;
next_to_clean        &amp;lt;52&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Technically, this is a problem with the &lt;strong&gt;ring buffers&lt;/strong&gt; of the network card. Think of it like a conveyor belt. The system puts packets on the back (&lt;code&gt;TDT&lt;/code&gt; - Transmit Descriptor Tail) and the card sends them off the front (&lt;code&gt;TDH&lt;/code&gt; - Transmit Descriptor Head).&lt;/p&gt;

&lt;p&gt;In my case, the logs showed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TDH (Head): 52&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TDT (Tail): 73&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means the system is queueing packets, but the hardware isn't processing them on the front end. The network card is simply frozen internally. Because the server is connected to the LAN via a bridge, this hang clogs up the entire Fritzbox switch port and takes down the rest of the wired network with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does This Happen?
&lt;/h2&gt;

&lt;p&gt;The culprit is a feature called &lt;strong&gt;EEE&lt;/strong&gt; (Energy Efficient Ethernet), also known as IEEE 802.3az. The Intel i219-LM card tries to save power when there's little activity. But in combination with virtualization under Proxmox, it sometimes doesn't wake up properly. This leads to timing problems and ends in a Hardware Unit Hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;I applied three steps to get the Lenovo stable again:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Adjust Driver Options
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;/etc/modprobe.d/e1000e.conf&lt;/code&gt; I added the following line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="n"&gt;e1000e&lt;/span&gt; &lt;span class="n"&gt;InterruptThrottleRate&lt;/span&gt;=&lt;span class="m"&gt;0&lt;/span&gt;,&lt;span class="m"&gt;0&lt;/span&gt;,&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;EEE&lt;/span&gt;=&lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This disables Energy Efficient Ethernet and interrupt throttling at the driver level.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Disable Offloading
&lt;/h3&gt;

&lt;p&gt;So-called &lt;strong&gt;offloading&lt;/strong&gt; refers to functions where the card takes work off the CPU (TSO, GSO, GRO). That sounds good in theory, but often leads to problems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ethtool &lt;span class="nt"&gt;-K&lt;/span&gt; nic0 tso off gso off gro off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Create a Systemd Service
&lt;/h3&gt;

&lt;p&gt;To make these settings persist after every reboot, I created a systemd service:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/etc/systemd/system/nic-stable.service&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Disable NIC offloading and EEE for stability&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/ethtool -K nic0 tso off gso off gro off&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/ethtool -s nic0 eee off&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then activated it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;nic-stable.service
systemctl start nic-stable.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And an initramfs update to load the modprobe config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;update-initramfs &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Rescue Mission Conclusion
&lt;/h2&gt;

&lt;p&gt;It wasn't a bug in Proxmox or the Fritzbox. It was simply the hardware losing the thread while trying to save power. By the way, the Fritzbox doesn't see any of this because the problem happens at &lt;strong&gt;Layer 2&lt;/strong&gt; the network card just hangs before any IP packets can even be sent.&lt;/p&gt;

&lt;p&gt;Since these adjustments, the system has been running without a single dropout. We were finally able to finish &lt;em&gt;Lord of the Rings&lt;/em&gt; without interruption. If your LAN ever goes down for no apparent reason, take a look at your server's kernel logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dmesg &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Often it's just a network card that slept a little too deeply. 🛌💤&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>homeserver</category>
      <category>networking</category>
      <category>proxmox</category>
    </item>
    <item>
      <title>I Built a Chimney Calculator Because I Wanted a Wood Stove</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Fri, 21 Aug 2026 07:17:45 +0000</pubDate>
      <link>https://dev.to/cdieck88/i-built-a-chimney-calculator-because-i-wanted-a-wood-stove-254m</link>
      <guid>https://dev.to/cdieck88/i-built-a-chimney-calculator-because-i-wanted-a-wood-stove-254m</guid>
      <description>&lt;h2&gt;
  
  
  The origin story
&lt;/h2&gt;

&lt;p&gt;Early this year I decided to get a wood-burning stove for the house. Sounded simple. Pick a nice one, call the chimney sweep, done. Wrong.&lt;/p&gt;

&lt;p&gt;The first question the sweep asked: "Which flue are you connecting to?" My house has a secondary shaft. Rectangular cross-section, not the standard 150mm round. It was originally just a ventilation shaft. Never designed for a combustion appliance. The sweep told me I needed a cross-section calculation according to DIN EN 13384-1 to prove the combination would even work. Pressure balance. Temperature at the outlet. Condensation risk. The whole program.&lt;/p&gt;

&lt;p&gt;I looked at the math and got hooked. Not because it's pretty. It's not. It's pressure drops through friction, buoyancy from density differences between hot flue gas and cold ambient air, exponential temperature decay along insulated pipes, and dew point calculations that decide whether your chimney liner will dissolve in acid over time.&lt;/p&gt;

&lt;p&gt;I wanted to understand it. So I built a tool that does it.&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%2Fmoikcxbgxfewe1grwsfc.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%2Fmoikcxbgxfewe1grwsfc.png" alt="The main wizard showing the appliance selection step with a stove model selected and its technical parameters auto-filled (exhaust temp, mass flow, CO2 content)." width="800" height="703"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What DruckZugPro does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.druckzug.pro" rel="noopener noreferrer"&gt;druckzug.pro&lt;/a&gt; is a free chimney cross-section pre-check tool based on DIN EN 13384-1. You enter your stove's exhaust data and your chimney geometry. The tool runs the three core verification checks and tells you if the combination is feasible.&lt;/p&gt;

&lt;p&gt;This is not a certified calculation. You still need a professional for that. But it gives you a solid first answer before you spend money on a consultation that might end with "nope, doesn't work."&lt;/p&gt;

&lt;p&gt;Three checks run simultaneously:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pressure balance&lt;/strong&gt; — Is the natural draft strong enough to overcome all friction and pressure losses?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temperature check&lt;/strong&gt; — Is the gas temperature at the chimney outlet still above the dew point? (If not: condensation, acid, masonry damage.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-section adequacy&lt;/strong&gt; — Is the flue diameter large enough for the gas velocity to stay below critical limits?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pass all three and you have a good basis for the real calculation. Fail one and the tool tells you what's wrong and what you could change.&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%2F2tzjvpc7xy9w7u0cazcp.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%2F2tzjvpc7xy9w7u0cazcp.png" alt="The results summary showing all three checks (pressure, temperature, cross-section) with pass/fail badges, the numerical margins." width="800" height="714"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The physics that makes it interesting
&lt;/h2&gt;

&lt;p&gt;The core is a thermodynamic model. Flue gas enters the connecting pipe hot and cools exponentially as it travels upward. The cooling rate depends on the pipe material, insulation, diameter, ambient temperature, and mass flow rate.&lt;/p&gt;

&lt;p&gt;The available draft comes from buoyancy. Hot gas is less dense than cold ambient air. The density difference over the effective chimney height creates a pressure differential. That's your engine.&lt;/p&gt;

&lt;p&gt;Working against it: friction along the pipe walls, losses at bends and transitions, the outlet loss at the chimney top, and the operating pressure the stove itself needs.&lt;/p&gt;

&lt;p&gt;The temperature check is where it gets serious for the building. If the flue gas cools below its dew point before leaving the chimney, water and sulfuric acid condense on the inner walls. Over years, that destroys masonry. The tool computes the outlet temperature using the worst-case outdoor temperature and compares it against the fuel-specific dew point.&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%2Fih67skxm4rh9k9k0i82y.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%2Fih67skxm4rh9k9k0i82y.png" alt="The temperature profile chart showing the exponential cooling curve from inlet to outlet, with the dew point line marked. The gap between outlet temp and dew point should be visible." width="800" height="796"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chimney visualization.&lt;/strong&gt; An SVG diagram that renders your chimney geometry to scale. Height, diameter, connecting pipe, top adapter. Updates reactively when you change parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Features beyond the core check
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Appliance database.&lt;/strong&gt; Over a hundred stove models with their official technical data (exhaust temperature, mass flow, CO2 content, fuel type). Select your model and the form auto-fills. Data sourced from manufacturer specifications and the HKI registry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compatible appliances finder.&lt;/strong&gt; Flip the question. Instead of "does my stove fit my chimney?" ask "which stoves fit my chimney?" The tool runs a simplified DIN check against every appliance in the database and shows you which ones pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parameter tuner.&lt;/strong&gt; After the initial calculation, interactive sliders let you tweak chimney height, diameter, pipe length, and cross-section shape. Results update live. You can see exactly where the margins break or improve. Failing parameters get highlighted in red.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weather resilience chart.&lt;/strong&gt; Sweeps the calculation across a range of outdoor temperatures (from -20 to +35) and plots the pressure margin and temperature margin as curves. Shows you at which ambient temperature your chimney setup starts failing. Useful for understanding edge-case behavior in extreme cold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF export.&lt;/strong&gt; Generates a multi-page A4 report with all configuration data and calculation results. Uses pdfme for client-side generation. No server round-trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calculation appendix.&lt;/strong&gt; Shows the full intermediate values and formulas used. Every number is traceable. Molar mass, density, specific heat, thermal conductance, cooling factor, friction coefficient. If you want to verify the math by hand, everything is there.&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%2F309963ssf7jci4e5kkd5.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%2F309963ssf7jci4e5kkd5.png" alt="The calculation appendix section showing intermediate values with their formulas and computed numbers. Show the section with density, thermal conductance, and cooling factor visible." width="800" height="689"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro&lt;/strong&gt; for static site generation. German and English with localized routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; for all DIN 13384-1 calculation logic. Pure functions, no DOM, fully testable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nanostores&lt;/strong&gt; for reactive state. The config store triggers recalculation and UI updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chart.js&lt;/strong&gt; for pressure balance, temperature profile, weather resilience, and flow visualizations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KaTeX&lt;/strong&gt; for rendering mathematical formulas in the calculation appendix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdfme&lt;/strong&gt; for client-side PDF report generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ajv&lt;/strong&gt; with JSON Schema for configuration validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for styling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vitest&lt;/strong&gt; with fast-check for property-based testing on the thermodynamics module.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs in the browser. No backend. No accounts. Projects are saved in localStorage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture that made it clean
&lt;/h2&gt;

&lt;p&gt;The DIN 13384 calculation is a pure function. &lt;code&gt;ChimneyConfig&lt;/code&gt; goes in, &lt;code&gt;CalculationResult&lt;/code&gt; comes out. No side effects. No store access. No DOM.&lt;/p&gt;

&lt;p&gt;The pipeline runs in stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate input (structural and physical constraints)&lt;/li&gt;
&lt;li&gt;Compute flue gas properties (density, specific heat, molar mass)&lt;/li&gt;
&lt;li&gt;Compute pressure losses through the connecting pipe (friction + single-point losses at bends)&lt;/li&gt;
&lt;li&gt;Compute pressure losses through the chimney (friction + transition + outlet loss)&lt;/li&gt;
&lt;li&gt;Compute available draft (buoyancy from density differential over height)&lt;/li&gt;
&lt;li&gt;Run the three verification checks&lt;/li&gt;
&lt;li&gt;Generate recommendations if anything fails&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The recommendation engine is the part I'm most pleased with. When the pressure check fails, it binary-searches for the minimum chimney height that would pass. When the cross-section fails, it iterates through standard diameters and recommends the smallest one that works. Actionable output instead of just "fail."&lt;/p&gt;

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

&lt;p&gt;DIN EN 13384-1 is surprisingly elegant once you strip away the bureaucratic language. It's basically Newton's cooling law combined with the hydrostatic pressure formula and pipe friction factors. Three equations that interact. The difficulty is not in any single formula. It's in getting all the constants right (thermal transmittance per wall type, friction coefficients per material, outlet loss coefficients per termination type) and handling the edge cases cleanly.&lt;/p&gt;

&lt;p&gt;The hardest part was testing. The standard doesn't include reference calculations you can verify against. I had to cross-check results against commercial software outputs from a chimney sweep I know and manually verify intermediate values against textbook examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.druckzug.pro" rel="noopener noreferrer"&gt;druckzug.pro&lt;/a&gt; — Enter your stove and chimney data, get instant results.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.druckzug.pro/en/appliances/wood/" rel="noopener noreferrer"&gt;Compatible appliances&lt;/a&gt; — Find which stoves work with your existing chimney.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Free. No login. No tracking. If you're planning a stove installation and want a sanity check before calling the sweep, this saves you the guesswork.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>astro</category>
      <category>webdev</category>
      <category>diy</category>
    </item>
    <item>
      <title>The Cloud in a Cabinet</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:36:30 +0000</pubDate>
      <link>https://dev.to/cdieck88/the-cloud-in-a-cabinet-4daa</link>
      <guid>https://dev.to/cdieck88/the-cloud-in-a-cabinet-4daa</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/cdieck88/building-a-home-lab-with-a-lenovo-thinkcentre-m920q-5369"&gt;Part 1&lt;/a&gt; I covered the hardware: how the Lenovo M920q replaced my overwhelmed Synology as a compute node. The hardware is in place, Proxmox is humming. Now for the real question: how do the services reach the internet without turning the router into Swiss cheese?&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure as Code: Everything in Git
&lt;/h2&gt;

&lt;p&gt;Clicking containers together by hand is not for me. If I break something, I want to rebuild it with a single command. My setup: a &lt;a href="https://github.com/SlashGordon/portainer_iac" rel="noopener noreferrer"&gt;Git repository&lt;/a&gt; with one YAML file per service.&lt;/p&gt;

&lt;p&gt;Instead of dumping everything into one giant &lt;code&gt;docker-compose.yml&lt;/code&gt;, I built my services as modular Portainer stacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/gitea.yml" rel="noopener noreferrer"&gt;gitea.yml&lt;/a&gt;&lt;/strong&gt; My private code home, including a Gitea Runner for CI/CD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/jellyfin.yml" rel="noopener noreferrer"&gt;jellyfin.yml&lt;/a&gt;&lt;/strong&gt; Media streaming for after work. Movies live on the Synology, streaming runs on the M920q. For this to work, the NAS media volume needs to be mounted on the Docker host via NFS or SMB. In my case, a simple entry in &lt;code&gt;/etc/fstab&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  //192.168.1.x/media /mnt/nas-media cifs &lt;span class="nv"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/nas-credentials,uid&lt;span class="o"&gt;=&lt;/span&gt;1000,gid&lt;span class="o"&gt;=&lt;/span&gt;1000 0 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jellyfin then accesses &lt;code&gt;/mnt/nas-media&lt;/code&gt; through a bind mount.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/vaultwarden.yml" rel="noopener noreferrer"&gt;vaultwarden.yml&lt;/a&gt;&lt;/strong&gt; Password manager for all devices. More on this below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/web.yml" rel="noopener noreferrer"&gt;web.yml&lt;/a&gt;&lt;/strong&gt; Traefik as reverse proxy for my websites (blog, portfolio, howtolosemoneyfast etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/tunnel.yml" rel="noopener noreferrer"&gt;tunnel.yml&lt;/a&gt;&lt;/strong&gt; The Cloudflare Tunnel that ties everything together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;New service? Create a YAML file, deploy it as a stack in Portainer, done. Everything broken? &lt;code&gt;git pull&lt;/code&gt;, redeploy the stacks, back online.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare Tunnels: No Open Ports, Still Reachable
&lt;/h2&gt;

&lt;p&gt;The old way: open ports 80 and 443 on the router, set up DynDNS, and hope nobody rattles the front door. Today that is no longer necessary.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt; (&lt;code&gt;cloudflared&lt;/code&gt;) establishes an encrypted connection &lt;strong&gt;from the inside out&lt;/strong&gt; to Cloudflare. No inbound port needs to be opened. Cloudflare handles TLS termination, DDoS protection, and DNS.&lt;/p&gt;

&lt;p&gt;Two things that make this especially valuable for my setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No port forwarding.&lt;/strong&gt; My router stays completely locked down. Port scanners hit nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero Trust via Cloudflare Access.&lt;/strong&gt; Before anyone sees the login page of Jellyfin or Gitea, they must pass an email verification. Only approved email addresses get through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One subdomain per service.&lt;/strong&gt; In the Cloudflare dashboard you create a subdomain for each service and point it to the internal port. For example &lt;code&gt;jellyfin.example.com&lt;/code&gt; → &lt;code&gt;http://jellyfin:8096&lt;/code&gt;, &lt;code&gt;git.example.com&lt;/code&gt; → &lt;code&gt;http://gitea:3000&lt;/code&gt;. Each subdomain gets its own access rules. This keeps everything cleanly separated and lets you control who can reach each service individually.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Here is the path from the browser to each service:&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%2Fn2wc7z266iqiau4metzy.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%2Fn2wc7z266iqiau4metzy.png" alt="The architecture of my homelab" width="800" height="639"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;cloudflared&lt;/code&gt; container connects to Cloudflare automatically on startup. All services sit on the same Docker network as cloudflared and are reachable directly. Only the static websites (blog, portfolio) have Traefik in front as a reverse proxy, because multiple domains need to be routed to different Nginx containers. The Synology remains a pure storage node: movies and backups.&lt;/p&gt;

&lt;p&gt;Important: local traffic should not go through the Cloudflare Tunnel. When the TV in the living room streams a movie, it makes no sense to send the data to Cloudflare and back into your own network. That costs latency and bandwidth. Instead, TV and notebook connect directly to Jellyfin via IP (port 8096). The tunnel is only for access from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vaultwarden Instead of KeePass + Google Drive
&lt;/h2&gt;

&lt;p&gt;My previous setup: a KeePass database on Google Drive, the same &lt;code&gt;.kdbx&lt;/code&gt; on every device. Works fine until you change a password on the laptop while the phone still has the old version open. Save, sync conflict, two files. Then you get to merge by hand and hope nothing is lost. That happened often enough that I got tired of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/dani-garcia/vaultwarden" rel="noopener noreferrer"&gt;Vaultwarden&lt;/a&gt; is a Rust reimplementation of the Bitwarden server API. The trick: you use the official &lt;a href="https://bitwarden.com/download/" rel="noopener noreferrer"&gt;Bitwarden clients&lt;/a&gt; and browser extensions, point the server URL to your own domain, and log in. The clients cannot tell the difference from hosted Bitwarden, but the data stays with me.&lt;/p&gt;

&lt;p&gt;Sync works across all devices, autofill in browsers and on mobile just works, and sharing passwords with the family is handled through organizations. The container uses less than 100 MB of RAM.&lt;/p&gt;

&lt;p&gt;For me this was the service that justified the whole homelab effort the fastest. No more Google Drive in the chain, no more broken &lt;code&gt;.kdbx&lt;/code&gt; files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment via Shared Volumes
&lt;/h2&gt;

&lt;p&gt;One detail I am quite happy with: how the websites go from git push to live without any file copying or rsync.&lt;/p&gt;

&lt;p&gt;The Gitea Runner has access to specific Docker volumes listed as &lt;code&gt;valid_volumes&lt;/code&gt; in its config, for example &lt;code&gt;blog-astro-content-prd&lt;/code&gt; or &lt;code&gt;howtolosemoney-content-prd&lt;/code&gt;. When a CI pipeline runs, the runner builds the static site and writes the output directly into the matching volume. The Nginx containers in &lt;code&gt;web.yml&lt;/code&gt; mount exactly these volumes as read-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# web.yml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;blog-astro-web-prd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;blog-astro-content-prd:/usr/share/nginx/html:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# gitea.yml (runner config excerpt)&lt;/span&gt;
&lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;valid_volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blog-astro-content-prd"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;howtolosemoney-content-prd"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;portfoliomanager-content-prd"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git push, runner builds, result lands in the volume, Nginx serves it. No SCP, no webhook, no second deploy step. The Nginx containers do not even notice anything changed because they just read from the filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  FritzBox, IP Changes and the Bypass Rule
&lt;/h2&gt;

&lt;p&gt;Cloudflare Access protects my services with email verification. But from my own network I do not want to go through that every time. For that there is a Zero Trust bypass policy that allows my public IP through. Problem: the FritzBox gets a new IP on every forced reconnect.&lt;/p&gt;

&lt;p&gt;The solution is a webhook. In the FritzBox under "Internet &amp;gt; Permits &amp;gt; DynDNS" there is an update URL pointing to my &lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/hooks.yml" rel="noopener noreferrer"&gt;nas-manager-hooks&lt;/a&gt; container. When the IP changes, the FritzBox calls the webhook which triggers a &lt;a href="https://github.com/SlashGordon/portainer_iac/blob/main/nas-manager/scripts/fritzbox-dyndns-update.py" rel="noopener noreferrer"&gt;Python script&lt;/a&gt; that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetches the current IPv4 and IPv6 via external services (ipify, ident.me)&lt;/li&gt;
&lt;li&gt;Calculates the /64 network from the IPv6 address&lt;/li&gt;
&lt;li&gt;Updates the Cloudflare Zero Trust bypass policy via API with the new IPs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why fetch externally? The FritzBox passes the IPv4 in the DynDNS callback but no IPv6 address. So the script fetches IPv6 itself via &lt;code&gt;api6.ipify.org&lt;/code&gt; with &lt;code&gt;v6.ident.me&lt;/code&gt; as fallback. If the lookup fails it retries up to five times with exponential backoff.&lt;/p&gt;

&lt;p&gt;The result: IP changes, FritzBox reports in, script runs, bypass policy is up to date. No cron job, no polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Performance CPU usage during that: rarely above 30%. Thanks to the fiber connection, my friends cannot tell whether the server lives in my cabinet or in a data center.
&lt;/h2&gt;

&lt;p&gt;Some numbers from daily operation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RAM usage (all containers)&lt;/td&gt;
&lt;td&gt;~8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle power consumption&lt;/td&gt;
&lt;td&gt;~10 W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power consumption under load&lt;/td&gt;
&lt;td&gt;~35 W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active Docker containers&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What's Next in Part 3?
&lt;/h2&gt;

&lt;p&gt;Mysterious network outages, always right when a movie should be playing in the evening? In Part 3 I dig into the network logs to find out whether the M920q is really a good roommate or if it is secretly killing the Wi-Fi.&lt;/p&gt;

</description>
      <category>jellyfin</category>
      <category>homelab</category>
      <category>gitlab</category>
      <category>vaultwarden</category>
    </item>
    <item>
      <title>Building a Home Lab with a Lenovo ThinkCentre M920q</title>
      <dc:creator>Christoph Dieck</dc:creator>
      <pubDate>Tue, 18 Aug 2026 08:14:25 +0000</pubDate>
      <link>https://dev.to/cdieck88/building-a-home-lab-with-a-lenovo-thinkcentre-m920q-5369</link>
      <guid>https://dev.to/cdieck88/building-a-home-lab-with-a-lenovo-thinkcentre-m920q-5369</guid>
      <description>&lt;p&gt;It all started innocently enough. A little Jellyfin for the movie collection here, a small Gitea instance for my code there. My Synology DS223 handled the job without complaint. But once you get a taste for automating your own software pipeline, you quickly end up wanting a CI runner. And that is exactly where things went sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  When memory runs out
&lt;/h2&gt;

&lt;p&gt;One runner here, one build process there, and suddenly my NAS was telling me we had a massive memory problem. 2 GB of RAM is no luxury resort for containers. It is more like a studio apartment with a shared kitchen. As soon as the Gitea Runner kicked off a build, the system started gasping for air. The OOM killer came knocking.&lt;/p&gt;

&lt;p&gt;The conclusion was obvious: I need a real server. But without solar panels on the roof, the usual second-hand server monsters with their 200-watt power supplies were out of the question.&lt;/p&gt;

&lt;p&gt;My requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enough CPU and RAM for Docker containers, CI/CD runners, and media streaming&lt;/li&gt;
&lt;li&gt;35 watts max under load (electricity is not free)&lt;/li&gt;
&lt;li&gt;Under 400 euros all-in, including upgrades&lt;/li&gt;
&lt;li&gt;Compact enough to fit next to the NAS in the server cabinet&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Big power, tiny box
&lt;/h2&gt;

&lt;p&gt;After nights of browsing r/homelab and various forums, I landed on the quiet favorite of the home lab community: the &lt;strong&gt;Lenovo ThinkCentre M920q&lt;/strong&gt;. An off-lease unit for 160 euros. Barely larger than a box of chocolates, but it has everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real PCIe slot (for future 10GbE adventures)&lt;/li&gt;
&lt;li&gt;Two RAM slots (up to 64 GB DDR4)&lt;/li&gt;
&lt;li&gt;An M.2 slot for NVMe storage&lt;/li&gt;
&lt;li&gt;An Intel Core i5-8500T with six cores that quietly get the job done&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shopping list
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lenovo M920q (Refurbished)&lt;/td&gt;
&lt;td&gt;160 EUR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32 GB RAM (2x16 GB DDR4)&lt;/td&gt;
&lt;td&gt;100 EUR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 TB NVMe SSD (WD SN770)&lt;/td&gt;
&lt;td&gt;100 EUR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original 8 GB RAM (sold on eBay)&lt;/td&gt;
&lt;td&gt;-29 EUR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original 256 GB SSD (sold on eBay)&lt;/td&gt;
&lt;td&gt;-26 EUR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (net)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;305 EUR&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Selling the original components on eBay brought the total down to about 305 euros. For that money you get a machine that delivers more performance per watt than many a tower server. And it is quieter than the cooler box sitting next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The foundation: Proxmox, Docker, Portainer
&lt;/h2&gt;

&lt;p&gt;I went with Proxmox VE as the hypervisor. First order of business after installation: an Ubuntu Server VM with Docker and Portainer as the container management UI.&lt;/p&gt;

&lt;p&gt;I immediately made a rookie mistake: I installed the Portainer Community Edition. Only after some digging did I find out that as a private user you can use the &lt;strong&gt;Business Edition completely free for up to three nodes&lt;/strong&gt;. The BE offers access control, Git-based stack deployments, and automatic updates. All features you learn to appreciate as your setup grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Power consumption
&lt;/h2&gt;

&lt;p&gt;The M920q draws about &lt;strong&gt;10 watts at idle&lt;/strong&gt;. Barely more than an LED bulb. Under full Docker load with multiple containers running builds, streaming media, and pushing to Git, it peaks at around 35 watts.&lt;/p&gt;

&lt;p&gt;For context: a typical used rack server (Dell R720, HP DL380) idles at 80 to 150 watts doing nothing. Over a year, that difference adds up to roughly 100 to 200 EUR in electricity costs at German rates. The M920q pays for itself in saved power within two years compared to a traditional server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What runs on it
&lt;/h2&gt;

&lt;p&gt;After a few months of iteration, the Docker host now runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gitea&lt;/strong&gt; with a CI runner (my own Git forge with build pipelines)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jellyfin&lt;/strong&gt; (media streaming)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traefik&lt;/strong&gt; (reverse proxy)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; (secure remote access without port forwarding)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AdGuard Home&lt;/strong&gt; (network-wide ad blocking)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vaultwarden&lt;/strong&gt; (password manager)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grafana + Prometheus + Loki&lt;/strong&gt; (monitoring and log aggregation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrowdSec&lt;/strong&gt; (WAF/IDS in front of Traefik)&lt;/li&gt;
&lt;li&gt;Multiple Nginx containers serving static sites&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of that on a 305 EUR mini PC drawing 10 watts idle. The six cores and 32 GB RAM handle it without breaking a sweat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works for a home lab
&lt;/h2&gt;

&lt;p&gt;The ThinkCentre M-series machines are built for enterprise desktop use. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rock solid hardware (these run 24/7 in corporate fleets)&lt;/li&gt;
&lt;li&gt;Excellent Linux/Proxmox compatibility out of the box&lt;/li&gt;
&lt;li&gt;vPro/AMT for remote management if you want it&lt;/li&gt;
&lt;li&gt;Abundant supply on the used market as companies cycle their leases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The M920q specifically hits a sweet spot. The i5-8500T has 6 cores (no hyperthreading, but 6 real cores), supports up to 64 GB RAM, and the PCIe slot means you can add a 10GbE NIC or an HBA card for direct-attached storage later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with Portainer Business Edition.&lt;/strong&gt; The free three-node license is generous and the Git-based stack deployment alone is worth it. Being able to push a docker-compose change to a Git repo and have Portainer auto-deploy it removes a lot of manual SSH work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sell the original components immediately.&lt;/strong&gt; RAM and SSD prices drop fast. I listed mine the day the upgrades arrived and got 55 EUR back. Waiting a month would have netted me 30 at best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The NAS stays a NAS.&lt;/strong&gt; I moved all compute workloads off the Synology and let it do what it does best: store files, run scheduled backups, and serve NFS shares. It is much happier now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;35 watts is the ceiling, not the floor.&lt;/strong&gt; Most of the time the system idles well below that. My Grafana dashboard shows an average of 12 to 15 watts over a typical week. Only CI builds and transcoding push it higher.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over to you
&lt;/h2&gt;

&lt;p&gt;If you are running Docker workloads on a NAS and hitting memory limits, or if you are paying 20 to 30 EUR/month in electricity for a tower server that is mostly idle, the ThinkCentre M920q (or its newer siblings M920x, M930q, M90q) might be worth a look.&lt;/p&gt;

&lt;p&gt;What is your home lab hardware? Curious to hear what others are running and how you keep power consumption reasonable.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>proxmox</category>
      <category>selfhosted</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
