<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-AU">
  <title>Az</title>
  <id>https://az.id.au/</id>
  <link rel="self" href="https://az.id.au/index.xml"/>
  <link href="https://az.id.au/"/>
  <subtitle>Recent content on Az</subtitle>
  
    <author>
      <name>Az</name>
    </author>
  
  
    <updated>2026-05-02T00:00:00Z</updated>
  
  
  <entry>
    <id>https://az.id.au/dev/rain-radar-2/</id>
    <title type="html"><![CDATA[Rain Radar 2: Consultant Boogaloo]]></title>
    <link rel="alternate" href="https://az.id.au/dev/rain-radar-2/"/>
    <updated>2026-05-02T00:00:00Z</updated>
    <author><name>Az</name></author>
    <content type="html"><![CDATA[<p>Following on from my previous post showing my fancy little <a href="/dev/rain-radar-when-gis-is-not-gis/">rain radar monitor</a> which followed on from my <a href="/dev/dashboard-monitor/">dashboard monitor</a> I&rsquo;ve made some further updates. Not out of desire to add features but out of necessity since the Bureau Of Meteorology has kept <a href="https://www.bom.gov.au/news-and-media/website-release-31-march-2026">tweaking their website</a> to the <a href="https://www.abc.net.au/news/2025-10-23/bureau-of-meteorology-bom-new-website-changes-outrage/105924016">consternation of the public</a> while the <a href="https://www.theguardian.com/australia-news/2025/nov/24/bom-website-redesign-cost-revealed-96-5-m">cost of the website refresh continues to rise</a> and the <a href="https://www.thesaturdaypaper.com.au/news/environment/2025/02/01/exclusive-bom-diverted-hundreds-millions-cover-cost-blowouts">service continues to fall</a>, eventually causing my simple home project using open data to break.</p>
<p>Now in case anyone is in a similar situation of wanting to use the BoM rain radar PNG images as GIS for a cute little dashboard, I&rsquo;ll cover what I had to change first before any rant.</p>
<h2 id="rain-radar-via-ftp">Rain Radar Via FTP</h2>
<p>My previous example made <a href="/dev/rain-radar-when-gis-is-not-gis/#how-do-i-get-updates">HTTP calls to the rain radar images</a> but I found after the BoM&rsquo;s website refresh it would often crash due to various website outages and changes to their CDN and WAF suddenly deciding that one request every five minutes was a DoS attack. Of course, none of this would have been an issue if they provided easy and <a href="/dev/rain-radar-when-gis-is-not-gis/#no-api">open methods for accessing data</a>.</p>
<p>While I could try using the <a href="https://reg.bom.gov.au/">domain for their &ldquo;old website&rdquo;</a> I have about as much faith in that staying up as I have in the competency of any Big Four consultancy handed a fat government handout. However BoM does offer an <a href="https://www.bom.gov.au/catalogue/anon-ftp.shtml">FTP service</a> with a bunch of products including access to those rain radar images (you do not need to pay $2266/year for them). This was a method I considered in my first iteration but skipped so I could avoid adding FTP handling while foolishly assuming a critical government department would have a website that works.</p>
<p>Below you&rsquo;ll find a little Python class I built to handle pulling the BoM radar images and weather observations from the FTP server:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-python" data-lang="python"><span style="color:#f92672">import</span> numpy <span style="color:#f92672">as</span> np
<span style="color:#f92672">from</span> io <span style="color:#f92672">import</span> BytesIO, StringIO
<span style="color:#f92672">from</span> PIL <span style="color:#f92672">import</span> Image
<span style="color:#f92672">from</span> ftplib <span style="color:#f92672">import</span> FTP
<span style="color:#f92672">import</span> xml.etree.ElementTree <span style="color:#f92672">as</span> ET
<span style="color:#f92672">from</span> weather <span style="color:#f92672">import</span> Weather
<span style="color:#f92672">from</span> perth <span style="color:#f92672">import</span> Perth

HOSTNAME <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;ftp.bom.gov.au&#34;</span>
STATIONNAME <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;PERTH METRO&#34;</span>

<span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BomFtp</span>:
    <span style="color:#66d9ef">def</span> __init__(self):
        self<span style="color:#f92672">.</span>connection <span style="color:#f92672">=</span> FTP(HOSTNAME)
        self<span style="color:#f92672">.</span>connection<span style="color:#f92672">.</span>login()
        self<span style="color:#f92672">.</span>latest_names <span style="color:#f92672">=</span> []
    
    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">download_weather</span>(self) <span style="color:#f92672">-&gt;</span> Weather:
        self<span style="color:#f92672">.</span>connection<span style="color:#f92672">.</span>cwd(<span style="color:#e6db74">&#34;/anon/gen/fwo&#34;</span>)
        sp <span style="color:#f92672">=</span> StringIO()
        self<span style="color:#f92672">.</span>connection<span style="color:#f92672">.</span>retrlines(<span style="color:#e6db74">&#34;RETR IDW60920.xml&#34;</span>, sp<span style="color:#f92672">.</span>write)
        root <span style="color:#f92672">=</span> ET<span style="color:#f92672">.</span>fromstring(sp<span style="color:#f92672">.</span>getvalue())
        observation <span style="color:#f92672">=</span> {}
        observation[<span style="color:#e6db74">&#34;name&#34;</span>] <span style="color:#f92672">=</span> STATIONNAME
        <span style="color:#66d9ef">for</span> element <span style="color:#f92672">in</span> root<span style="color:#f92672">.</span>findall(f<span style="color:#e6db74">&#34;./observations/station[@stn-name={STATIONNAME}]/period/level/element&#34;</span>):
            observation[element<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;type&#34;</span>)] <span style="color:#f92672">=</span> element<span style="color:#f92672">.</span>text
        weather <span style="color:#f92672">=</span> Weather(observation)
        <span style="color:#66d9ef">return</span> weather
    
    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">new_name</span>(self, line: str):
        <span style="color:#66d9ef">if</span> line<span style="color:#f92672">.</span>startswith(<span style="color:#e6db74">&#34;IDR70A.T&#34;</span>) <span style="color:#f92672">and</span> line<span style="color:#f92672">.</span>endswith(<span style="color:#e6db74">&#34;.png&#34;</span>):
            self<span style="color:#f92672">.</span>latest_names<span style="color:#f92672">.</span>insert(<span style="color:#ae81ff">0</span>, line)

    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">rainfall_image_names</span>(self) <span style="color:#f92672">-&gt;</span> list[str]:
        self<span style="color:#f92672">.</span>connection<span style="color:#f92672">.</span>cwd(<span style="color:#e6db74">&#34;/anon/gen/radar&#34;</span>)
        self<span style="color:#f92672">.</span>connection<span style="color:#f92672">.</span>retrlines(<span style="color:#e6db74">&#34;NLST&#34;</span>, self<span style="color:#f92672">.</span>new_name)
        self<span style="color:#f92672">.</span>latest_names <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>latest_names[<span style="color:#ae81ff">0</span>:<span style="color:#ae81ff">4</span>]
        self<span style="color:#f92672">.</span>latest_names<span style="color:#f92672">.</span>reverse()
        <span style="color:#66d9ef">return</span> self<span style="color:#f92672">.</span>latest_names
    
    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">download_rainfall_image</span>(self, filename: str) <span style="color:#f92672">-&gt;</span> np<span style="color:#f92672">.</span>ndarray:
        bp <span style="color:#f92672">=</span> BytesIO()
        self<span style="color:#f92672">.</span>connection<span style="color:#f92672">.</span>retrbinary(f<span style="color:#e6db74">&#34;RETR /anon/gen/radar/{filename}&#34;</span>, bp<span style="color:#f92672">.</span>write)
        array <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>asarray(Image<span style="color:#f92672">.</span>open(bp))<span style="color:#f92672">.</span>copy()
        perth <span style="color:#f92672">=</span> array[Perth<span style="color:#f92672">.</span>x1:Perth<span style="color:#f92672">.</span>x2, Perth<span style="color:#f92672">.</span>y1:Perth<span style="color:#f92672">.</span>y2]
        rows <span style="color:#f92672">=</span> perth<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>]
        cols <span style="color:#f92672">=</span> perth<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">1</span>]
        low <span style="color:#f92672">=</span> [[], []]
        high <span style="color:#f92672">=</span> [[], []]
        <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">0</span>, rows <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>):
            <span style="color:#66d9ef">for</span> y <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">0</span>, cols <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>):
                <span style="color:#66d9ef">if</span> np<span style="color:#f92672">.</span>array_equiv(perth[x, y], [<span style="color:#ae81ff">127</span>, <span style="color:#ae81ff">254</span>, <span style="color:#ae81ff">255</span>, <span style="color:#ae81ff">255</span>]):
                    low[<span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>append(rows <span style="color:#f92672">-</span> x)
                    low[<span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>append(y)
                <span style="color:#66d9ef">elif</span> <span style="color:#f92672">not</span> np<span style="color:#f92672">.</span>array_equiv(perth[x, y], [<span style="color:#ae81ff">255</span>, <span style="color:#ae81ff">255</span>, <span style="color:#ae81ff">255</span>, <span style="color:#ae81ff">0</span>]):
                    high[<span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>append(rows <span style="color:#f92672">-</span> x)
                    high[<span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>append(y)
        <span style="color:#66d9ef">return</span> [low, high]
</code></pre></div><p>This is pretty similar to the code that I discussed in my previous post and was designed so I could replace the previous HTTP code without having to completely change how the rest fits. <em>It is purposefully barebones.</em> I don&rsquo;t handle errors, I don&rsquo;t ensure FTP disconnection, I hardcode the stuff I want. This is not professional code or ready-to-use FOSS; this is just so I can see if it&rsquo;s gonna start raining while I head to the kebab shop.</p>
<p>The <code>Weather</code> class is used by the graph to turn observations into data plotted in display boxes at the bottom and the <code>Perth</code> class contains the bounding box (in pixels) of the area I want to slice out of the image (that whole &ldquo;GIS when it&rsquo;s not GIS&rdquo; thing).</p>
<p>Because the FTP directory has each radar product with the timestamp in the name I can simply grep the product that is relevant, grab four file names, and then put them in reverse order to fit the existing display code. That let me delete a whole bunch of time handling code that would construct correct filenames, test for image presence, and add incrementing delay counters if a new image wasn&rsquo;t yet available. <em>We love deleting code.</em></p>
<p>To avoid making unecessary requests, I keep a copy of the four filenames currently in use and only check every five minutes for new files. If a new one exists, I only download that one and kick out the last one. Maybe it&rsquo;s overkill ensuring I don&rsquo;t download more than 10kb every five minutes but I like to avoid causing extra hassle for people on the other end. <em>*frustrated glare at The Bureau*</em></p>
<h2 id="status">Status</h2>
<p>It works!</p>
<p>It did crash the next day and I was fed up so I waited 24hrs before looking into it only to discover that all the products for IDR70 (Perth - Serpentine rain radar) were removed from the FTP server. There was nothing on the website about service outages or radar problems and when I viewed their website rain radar it did show data on their fancy new map.</p>
<p>I sent the BoM an enquiry message with specific details to see if it was an issue or purposeful but then decided to Google the specific radar product ID which lead me to the <a href="https://www.bom.gov.au/products/IDR70A.loop.shtml">&ldquo;reg.bom.gov.au&rdquo; old version</a> of that radar page which had a handy notice saying that the radar was down due to maintenance and would be up within 24hrs. Truly just a victim of coincidence. <em>However;</em> that feels like the kinda thing you should have on your public rain radar so users know that the results they are seeing are interpolated results from much further radars and thus are inaccurate.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>
<p>Thankfully the radar is online again and my dashboard is working wonderfully getting data via FTP.</p>
<h2 id="i-thought-you-hated-ftp">I thought you hated FTP?</h2>
<p>I still think <a href="/dev/rain-radar-when-gis-is-not-gis/#ftp-infosecurity">my previous comments</a> about how fucked it is that a government department focused on critical data dissemination relying entirely on FTP in the the Year Of Our Lord <del>2025</del> 2026 are relevant. Especially that for a lot of products they deign to <a href="https://reg.bom.gov.au/other/charges.shtml">charge us such outrageous prices</a>.</p>
<p>But if this is the only way I can reliably access BoM data I guess I&rsquo;ll take it.</p>
<h1 id="the-new-website">The New Website</h1>
<p>It&rsquo;s been available for a while now and to their credit the BoM have listened to user feedback and made changes, like swapping back to Rain reflectivity (dBZ) <a href="https://theconversation.com/stormy-weather-heres-what-went-wrong-with-the-bureau-of-meteorologys-website-redesign-268490">after public outcry</a> when the new website defaulted to Rain rate (mm/h).</p>
<p>I think with enough time, people have gotten used to the redesign and if you are a layperson using it to check the forecast it is sufficent. It mostly<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> does it&rsquo;s job. I feel like a lot of the attention and negative press it received was due to two factors:</p>
<ul>
<li>It was a complete change in both design, navigation, and experience.</li>
<li>It was debuted at the <a href="https://www.abc.net.au/news/2025-10-28/qld-government-criticises-changes-to-bom-website/105943192">worst time during extreme weather events</a>.</li>
</ul>
<p>The latter is absolutely a major cockup on the department&rsquo;s part. The former is a common experience when there is a major UX change to any service and it takes an adjustment period for people to adapt. However, there is a difference between something like Facebook circa 2010 (a website run by a private company that is not used by people to receive life saving advice) making sweeping website changes and the BoM (the opposite). A private company might want to do a refresh to push users towards new features or prevent themselves from fading into banality. A government department shouldn&rsquo;t invest $96.5 million of taxpayer money into a website refresh because <a href="https://www.abc.net.au/news/2025-10-23/bureau-of-meteorology-bom-new-website-changes-outrage/105924016">&ldquo;it was looking tired&rdquo;</a>.</p>
<h2 id="value-for-money-no">Value For Money? No.</h2>
<p>The BoM attempted to explain the <a href="https://www.bbc.com/news/articles/c2k4dy15nqqo">eye-watering cost</a>:</p>
<blockquote>
<p>&ldquo;The $96.5 million that we&rsquo;re talking about was not just the front end of the website, the tip of the iceberg that the public sees, but the back end, which sees data flowing from tens of thousands of pieces of equipment in the field, to the supercomputer that does all the modelling, right through to systems that actually forecast the weather and put it through to the website&rdquo; - <a href="https://www.abc.net.au/news/2025-11-24/bom-website-approved-by-coalition-ceo-says/106047518">BoM CEO Stuart Minchin</a></p>
</blockquote>
<p>But all of that was covered under the BoM&rsquo;s <a href="https://www.itnews.com.au/news/boms-seven-year-technology-transformation-cost-866m-611371">$866m Robust transformation project</a> including the supercomputer, modelling/forecasting, IoT data collection, asset maintenance/upgrades, and more. It is fair to say that the BoM website would cost a lot more than a <a href="https://www.govcms.gov.au/">GovCMS</a> website because there is a lot of integration with the BoM&rsquo;s backend data platforms that have masses of frequently updating data but that should still be possible for under $10m. And I&rsquo;m pretty confident because I have worked on more complex, public-facing platforms across government and private sector that all came in under eight figures. While I (usually) consider it improper to speak definitively on a project I wasn&rsquo;t involved in, the fact that all the data on their website is also (and has always been) available on their FTP server does feel like they&rsquo;ve just pissed our money up the wall.</p>
<p>Back in 2010 I remember submitting feedback to the BoM about getting &ldquo;bom.gov.au&rdquo; added as an option to visit the website (back then, it was &ldquo;<a href="http://www.bom.gov.au">www.bom.gov.au</a>&rdquo; or nothing) and received a response outlining the difficulties due to running everything from DNS to webservers on their own hardware with (pre-cloud days) while receiving over 10,000,000 hits a day. But with the government&rsquo;s push to commercial cloud, using platforms that handle auto-scaling, CDNs for burst traffic, and a bunch of ACSC default security templates to apply to services, those sort of issues are not what caused the price tag.</p>
<h2 id="consultancy-driven-development">Consultancy Driven Development</h2>
<p>So why did it cost so much? Because the actual dole bludgers sucking on the teat of taxpayer-funded welfare are supermassive consultancy firms.</p>
<p>Whether they be &ldquo;professional services&rdquo; (<a href="https://fortune.com/2025/10/07/deloitte-ai-australia-government-report-hallucinations-technology-290000-refund/">Deloitte</a>, <a href="https://www.abc.net.au/news/2023-12-20/defence-data-contract-kmpg-weak-indefensible-review-finds/103247476">KPMG</a>, <a href="https://en.wikipedia.org/wiki/PwC_tax_scandal">PwC</a>, <a href="https://www.theguardian.com/business/2024/jan/24/ey-oceania-accused-conflict-of-interest-australia-government-contracts-climate-policy">EY</a>) or &ldquo;technology providers&rdquo; (<a href="https://www.innovationaus.com/permissions-denied-home-affairs-tech-platform-gets-the-hook/">Accenture</a>, <a href="https://www.itnews.com.au/news/defences-erp-bill-with-ibm-hits-575m-619785">IBM</a>) - they are able and content to demand an outrageous price and then be handed the tender because <a href="https://www.forbes.com/sites/duenablomstrom1/2018/11/30/nobody-gets-fired-for-buying-ibm-but-they-should/">they&rsquo;re a reliable and safe firm</a>. They&rsquo;re then happy to blow out the costs and timeline by 40% because it&rsquo;s <a href="https://en.wikipedia.org/wiki/Sunk_cost">too late for the government to back out</a> only to eventually produce a half-baked solution that doesn&rsquo;t meet the client&rsquo;s needs.</p>
<p>Having been required to work alongside these types many times over my career seen that they have &ldquo;ready to go&rdquo; generic template solutions for common digital transformation projects despite selling it each time as custom-built from the ground up. I would be okay with this (why reinvent the wheel?) if it meant they delivered on time or on budget or priced it less than what it would cost for a small/specialist consultancy or internal development team to build from scratch. Instead every time we see are given a square wheel with a massive markup. Relying on generic templates also means they have no desire to confirm it&rsquo;s fit for purpose (&ldquo;we didn&rsquo;t realise a department that tracks offshore fishing would have a need for GIS data&rdquo;) and no willingness to use the existing systems (&ldquo;we don&rsquo;t care that your entire infrastructure is on Azure, we need to deliver this on AWS&rdquo;).</p>
<p>We were sold the lie that it&rsquo;s cheaper for the private sector to build something than the government, which is why we slashed every department&rsquo;s capability to build or develop internally and castigated them if they dared step out of line. But constantantly handing fistfuls of cash to companies that can screw Australian citizens over with impugnity, knowing very well that <a href="https://www.abc.net.au/news/2026-03-24/company-in-96m-bom-site-redesign-gets-16m-contract/106478264">they&rsquo;ll still get the next tender</a>, has fared the Australian public much worse.</p>
<h2 id="what-can-i-do">What Can I Do?</h2>
<ul>
<li>If you work for a major consultancy, quit. Take a pay cut and work for a smaller company that&rsquo;s not run by ghouls before it turns you into one.</li>
<li>If someone you know works for a major consultancy? Make fun of them until they quit and work somewhere that doesn&rsquo;t exist to screw us all over.</li>
<li>If a project is going out to tender, see if it can be broken down into smaller, well-defined tenders that can go out to more reliable specialised consultancies.</li>
<li>If you&rsquo;re on an assessment panel for a tender, make sure to review responses and factor in a lack of specificity or understanding of the project/domain, as well as a history of previous fuckups.</li>
<li>File <a href="https://www.righttoknow.org.au/">Freedom Of Information</a> requests for projects by big consultants and publish your analysis/findings.</li>
<li>When fancy new govt IT projects (rolled out over budget and over schedule) ask for feedback<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>, give it frankly and critically. Make sure you point out that the government department performs important functions but this specific under delivers for how much it cost.</li>
<li>When it comes time to vote at the state or federal level, remember which parties have a minister-to-consultancy job pipeline.</li>
</ul>
<p>Try to find workarounds for crappy systems. If you can find ways around having to use some sub-par overpriced platform that&rsquo;s taken away what you used to use, look for other ways the department provides data to make a neat little dashboard so you and your <a href="https://studionyx.co/">spousey-boo</a> can glance at the rain, weather, and forecast before you leave home.</p>
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1" role="doc-endnote">
<p>My amazing <a href="https://scorpinc.solutions/">co-CEO</a> <a href="https://scorpinc.social/@shmouflon">@shmouflon</a> showed me that if you&rsquo;re on the mobile app (and only on the mobile app) looking at the radar it will have a message down the bottom saying if there is maintenance at one of the towers. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>Not completely. Aforementioned lack of warnings about radar outages for example. <a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>Also why does a government website or app keep popping up modals requesting I leave a review? Because massive consultancies wants to juice review numbers as false metric of success of a project. Due to <a href="https://www.sciencedirect.com/science/article/abs/pii/S1569190X17300874">self-selection bias</a>, people are more likely to seek out the opportunity to review something when they have a major positive or negative experience. With a government app that just tells you the weather, the former is less likely as it is purely functional. So developers use <a href="https://en.wikipedia.org/wiki/Dark_pattern">dark patterns</a> like pressure imposing/nagging (<a href="https://dsb.gov.au/sites/dsb.gov.au/files/2024-11/report-patterns-in-the-dark.pdf">p100</a>) designed to get more feedback. <a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</section>
]]></content>
  </entry>
  
  <entry>
    <id>https://az.id.au/dev/static-file-webmap/</id>
    <title type="html"><![CDATA[Static File Webmap]]></title>
    <link rel="alternate" href="https://az.id.au/dev/static-file-webmap/"/>
    <updated>2025-09-22T00:00:00Z</updated>
    <author><name>Az</name></author>
    <content type="html"><![CDATA[<p>Listen, one of the roadblocks to protecting your users' privacy is basemaps. You want a nice imagery layer, your users want a nice imagery layer, and you both want to protect each other&rsquo;s privacy. The problem is you forgot to ask Old Father Google (or another provider with the same creepy attitude to your data) for some privacy and so he gets to sit in the corner, watching and breathing heavily while you both awkwardly do GIS while trying to ignore him. But thanks to the power of Open Data and Open Source you can make your own and stop giving away your users' data.</p>
<p>But in true open data and open source fashion:</p>
<ul>
<li>It&rsquo;s not really easy or accessible to the average user,</li>
<li>Can quickly get expensive,</li>
<li>And anything that goes wrong is your responsibility.</li>
</ul>
<p>But that&rsquo;s some of the common trade-offs when you&rsquo;re dealing with open source software and hand-rolled solutions - the advantage to commercial stuff is that plug&rsquo;n&rsquo;play with some great tooling that only occasionally<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> tends towards vendor lock-in. But if you&rsquo;ve got some tech chops and want a satellite basemap you can keep reading because I&rsquo;ll walk through creating a tiled, static file imagery basemap suitable to use with your choice of you GIS software.</p>
<h2 id="why-static-file">Why Static File?</h2>
<p>I run a lot of my own servers and services so I will always recommend the two reigning queens of open source spatial data servers (<a href="https://mapserver.org/">MapServer</a> and <a href="https://geoserver.org/">GeoServer</a>) but they do have the same fallibility as most GIS software: stonkingly high hardware requirements.</p>
<p>GIS operations can be computationally expensive if you&rsquo;re trying to pull together a smaller zoomed-out tile from a massive GeoTIFF. And scanning through a large file - or multiple with a VRT - will smash your disk I/O. Of course, that can be ameliorated by using a GeoTIFF with internal tiling and pyramiding but at that point we&rsquo;re just doing a tiled basemap with extra processing unless we also add a caching layer and then&hellip; Oh we&rsquo;ve just recreated the static file system.<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup></p>
<p>The main reason I prefer this option is I can take advantage of cheap cloud static hosting (like I use for this blog<a href="/ops/coudfront-and-chills/">[1]</a><a href="/ops/ssl-on-aws-static-sites/">[2]</a>) so I&rsquo;m also not having to smash my home internet upload bandwidth just so people can look at pretty satellite imagery on AzMaps (a division of ScorpInc).</p>
<h2 id="where-do-i-get-data">Where Do I Get Data?</h2>
<p>There&rsquo;s a bunch of cool satellites in orbit that are capturing high resolution scans of the world on a regular basis. A few of the good&rsquo;uns are below with resolution (how big a pixel is) and revisit time (frequency of new image, same spot):</p>
<ul>
<li><a href="https://www.esa.int/Applications/Observing_the_Earth/Copernicus/Sentinel-2">Sentinel-2</a> - 10m, 5 days.</li>
<li><a href="https://landsat.gsfc.nasa.gov/satellites/landsat-9/">Landsat-9</a> - 30m, 16 days.</li>
<li><a href="https://landsat.gsfc.nasa.gov/satellites/landsat-8/">Landsat-8</a> - 30m, 16 days.</li>
</ul>
<p>These are pretty solid options for creating a high resolution static basemap - one that you don&rsquo;t plan on changing very often.</p>
<p>If you want higher frequency images and aren&rsquo;t too worried about clouds and can also build yourself a good update pipeline I&rsquo;d look at older satellites rocking the <a href="https://en.wikipedia.org/wiki/Moderate_Resolution_Imaging_Spectroradiometer">MODIS</a> or <a href="https://en.wikipedia.org/wiki/Visible_Infrared_Imaging_Radiometer_Suite">VIIRS</a> sensors which have a coarser resolution (500m+) but can get you multiple images a day with a good source.</p>
<p>There&rsquo;s a lot of places you can get data from - if you search for the satellite/sensor name and some form of &ldquo;cloudless mosaic&rdquo;/&ldquo;download&rdquo;/&ldquo;data cube&rdquo; you&rsquo;ll find a bunch of options. I grabbed the <a href="https://aws.amazon.com/marketplace/pp/prodview-ydi2yfnsbz6bg">ESA 2021 WorldCover Sentinel-1 and Sentinel-2 10m composite</a> hosted on the AWS open data marketplace.</p>
<p>Now, you probably don&rsquo;t want to download An Entire Mosaic™ if you value your hard drive space (for local processing) and the credit card attached to your cloud provider ($0.039 per gigabyte is cheap until it&rsquo;s not). I drew a polygon in WGS84 over an area of the greatest state/province in the world and then grabbed the bounding box coordinates of it. Accessing the data cube with the AWS CLI you can find the folder structure starting here:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-sh" data-lang="sh">aws s3 ls s3://esa-worldcover-s2/rgbnir/2021/
</code></pre></div><p>You&rsquo;ll find folders for each latitude, prefixed with N/S. In each folder you&rsquo;ll find a GeoTIFF for each latitude/longitude like:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-sh" data-lang="sh">ESA_WorldCover_10m_2021_v200_S31E115_S2RGBNIR.tif
</code></pre></div><p>Using <code>aws s3 sync</code> and some smart file globbing you should be able to grab all the files you want for a particular area.</p>
<h2 id="each-pixel-is-10m">Each pixel is 10m!?</h2>
<p>Yeah.</p>
<p>This means you won&rsquo;t be able to pick out cars or people<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup> but this is one of the limitations of open source satellite imagery. Companies like Google and Esri can afford to pay for higher resolution satellite or aerial imagery. If you&rsquo;ve got the dosh there&rsquo;s a number of satellites that do 30cm to 1m resolution including tasks and regular interval but you&rsquo;ll probably be on the hook for dealing with clouds, lighting, etc so plan in advance and maybe spare me a few bob if you&rsquo;re throwing around that cash?</p>
<h2 id="measure-once-cut-once">Measure once, cut once.</h2>
<p>The next steps involve using our favourite open source spatial desktop suite, <a href="https://qgis.org/">QGIS</a>. Pull in all the rasters you downloaded and you might notice how some of the tiles have slightly different colouring to the rest and that&rsquo;s because the default for QGIS is showing each raster in isolation rather than averaged against each other. To fix this we&rsquo;ll build a VRT by going to Raster -&gt; Miscellaneous - Build Virtual Raster. Here, make sure you select all the layers for input and export it to a VRT.</p>
<p>Open a new QGIS project with just the VRT layer and open the processing toolbox (Processing -&gt; Toolbox) and type <code>XYZ</code> into the search box. Under &ldquo;Raster tools&rdquo; we&rsquo;ll want Generate XYZ Tiles (Directory). Double click on that, set the extent to the VRT layer and then change all the options appropriate to you. You might need to play a bit with the minimum and maximum zoom levels depending on how big the extent of your area is as well as how detailed. Smaller numbers are a coarser view up until you&rsquo;re zoomed out looking at the whole world, higher numbers are finer detail and maybe looking at individual hairs on an ants leg so you need to take into account your base image resolution<sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup> and how long it will to take to process and how much space the result takes up. The example I&rsquo;ll use below has zoom levels 9 to 16.</p>
<p>Depending on what framework you&rsquo;ll be using to display the data I&rsquo;d usually recommend PNG, allow transparency (so leave the optional background colour). Make sure you create an output directory for it and for sanity checking create the output HTML file before running the process.</p>
<p>Then grab a coffee or <a href="https://www.motherenergydrink.com/en-au/products/original/">appropriate energy drink</a> and wait.</p>
<h2 id="completed">Completed!</h2>
<p>Cool. Now you can serve those files on a webserver or upload them to some form of static hosting and Bob&rsquo;s your uncle.</p>
<p>You&rsquo;ve now got a satellite imagery basemap without clouds and adjusted for daytime and colour and most of all it&rsquo;s privacy preserving because you&rsquo;re not required to load any JS bundles or tiles from a 3rd party who cares more about your users as a product than real people.<sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup></p>
<p>Want to know what it looks like? Check out this demo below:</p>
<link rel="stylesheet" href="/lib/leaflet@1.9.4/leaflet.css">
<script src="/lib/leaflet@1.9.4/leaflet.js">
</script>
<style>
#map { height: 500px; }
</style>
<div id="map"></div>
<script>
  var map = L.map("map", { attributionControl: false }).setView(
    [-31.949, 115.95],
    11
  );
  L.control.attribution({ prefix: false }).addTo(map);
  var tilesource_layer = L.tileLayer(
    "https://s3.ap-southeast-2.amazonaws.com/cdn.az.id.au/2025-09-22-static-webmap/{z}/{x}/{y}.png",
    { minZoom: 9, maxZoom: 16, tms: false, attribution: "Created with 💀 at ScorpInc" }
  ).addTo(map);
</script>
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1" role="doc-endnote">
<p>Always. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>Even if QGIS or ArcGIS Pro can can render a view in a couple of seconds when you&rsquo;re looking at a massive GeoTIFF, the end result will suck if twenty (20) people are visiting your site at the same time and all trying to grab a bunch of tiles. <a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>&lsquo;cept ya mum. <a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4" role="doc-endnote">
<p>No, if you get 10m imagery and then choose a really fine resolution it won&rsquo;t make it more detailed. Like any &ldquo;AI upscaling&rdquo; it&rsquo;s not revealing anything new it&rsquo;s just making shit up. <a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5" role="doc-endnote">
<p>I don&rsquo;t view you as users or products tbh. More of like a delectable snack? By the way I love what you&rsquo;re doing with your hair, you should come spend a weekend at my remote mountain castle. You&rsquo;re welcome to bring any friends who are O+ and have low bio-accumulation of heavy metals in their blood. <a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</section>
]]></content>
  </entry>
  
  <entry>
    <id>https://az.id.au/dev/rain-radar-when-gis-is-not-gis/</id>
    <title type="html"><![CDATA[Rain Radar - When GIS Is Not GIS]]></title>
    <link rel="alternate" href="https://az.id.au/dev/rain-radar-when-gis-is-not-gis/"/>
    <updated>2025-08-10T00:00:00Z</updated>
    <author><name>Az</name></author>
    <content type="html"><![CDATA[<p>The <a href="http://www.bom.gov.au/">Bureau Of Meteorology</a> is Australia&rsquo;s weather service, colloquially referred to as &ldquo;BoM&rdquo; and a moniker they&rsquo;ve accepted so well that they reach out to media outlets confirming that&rsquo;s how they want to be referred in order to prevent confusion. Which is really good for a government department rather than doing something dorky like a divorced mid-40&rsquo;s housemate who demands people call him &ldquo;Diesel&rdquo;, <a href="https://www.themandarin.com.au/202938-boms-no-good-very-bad-day-as-name-change-storms-twitter/">a terrible moniker he gave himself</a> and then nicking a <a href="https://www.macquariedictionary.com.au/counting-our-blue-swimmers/">pineapple</a> from <a href="https://www.abc.net.au/news/2022-10-19/bureau-meteorology-rebrand-cost-200-thousand/101552620">your wallet to post an ad telling everyone that&rsquo;s his nickname</a>.</p>
<p>They provide all the usual stuff layfolk would expect from a weather service: forecast, observations, warnings, and cool rain radars so you can see <a href="https://youtu.be/4McUFRM6nLA">if the rains are &lsquo;ere</a>. This last part is the focus of this blog, because the data isn&rsquo;t in comfy GIS formats and I wanted a way we could display it at home <a href="/dev/dashboard-monitor/">on my dashboard monitor</a> and know whether it was safe to head out for a quick bevvie or duck down to the convenience store without needing a brolly.</p>
<p>And you know what? It works. Ain&rsquo;t no fucken&rsquo; way I&rsquo;m going to the servo for darts in that.</p>
<p><img src="/img/2025-08-10-rain-radar-monitor.jpg" alt="An old 4:3 ratio Dell LCD computer monitor sits on a desk, showing a terminal interface using limited character and colour palettes to render rainfall mapped over the local area. Bright green squares represnt some rain in the last five minutes, bright red squares representing heavy rain in the last five minutes. Duller green and red dots represent light and heavy rain detected in the 15 minute period prior. Two sine waves oscillate across the screen for no discernable purpose and some labels down the bottom give information about the time, temperature, and wind."></p>
<h2 id="the-existing-radar">The Existing Radar</h2>
<p>The BoM has a handy page where you can <a href="http://www.bom.gov.au/australia/radar/">see all the radars they operate around Australia</a> and click the one closest to you. From a particular radar there&rsquo;s also a bunch of little toggles or sub-pages you can check out depending on your needs but we&rsquo;ll focusing on the <a href="http://www.bom.gov.au/products/IDR70A.loop.shtml#skip">5 min rainfall loop for Perth (Serpentine)</a>. What you&rsquo;ll notice is a cute animated image that shows the rainfall detected in five (5) minutes intervals that steadily loops. Handy for seeing not just where it has rained but also which direction it is heading and how heavy it is.</p>
<p>The thing is, it&rsquo;s not an animated GIF. If you&rsquo;re willing to <a href="https://techcrunch.com/2021/10/15/f12-isnt-hacking-missouri-governor-threatens-to-prosecute-local-journalist-for-finding-exposed-state-data/"><del>be a hacker</del></a> inspect element it&rsquo;s a collection of PNGs<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> with transparencies for contextual layers (range, locations, topography) that you can toggle under &ldquo;Map features&rdquo;. Interesting and relatively old-school way of handling it - but that&rsquo;s not a bad thing! Old tech that works is better than new tech that doesn&rsquo;t. The rain &ldquo;animation&rdquo; is also a set of PNG images with the latest radar observations and a JavaScript event timer that cycles them every second and also forces a page refresh when it believes there should be a more recent frame available.</p>
<blockquote>
<p>Note: This reduces accessibility by forcing you to have JavaScript enabled to run rather than use of a GIF but it&rsquo;s not my place to make feature requests<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> for stuff <a href="http://www.bom.gov.au/radar/IDR704.gif">they don&rsquo;t have</a>.</p>
</blockquote>
<h2 id="id-love-to-use-this-data">I&rsquo;d Love To Use This Data</h2>
<p>I don&rsquo;t want to have an old monitor with a full desktop experience and browser open to a series of garish and constantly refreshing pages. I just want to be able to get the (non-sensitive) data that my tax dollars helped create and then be able to use it in a format that works for me because I&rsquo;m a firm believer in <a href="https://en.wikipedia.org/wiki/Open_data#In_government">Open Data</a> much like <a href="https://data.gov.au/about">some governments</a> so it would be nice to have it easily accessible.</p>
<p>PNG images aren&rsquo;t georeferenced so you can&rsquo;t overlay them on a map. Because the radar is not located on my apartment I have to work out where I am in relation to the image and then work out the rainfall. I&rsquo;ll also need a way of automating when new rainfall observation images arrive and a nice way to display them.</p>
<p>So let&rsquo;s do it.</p>
<h2 id="where-am-i">Where Am I?</h2>
<p>I downloaded the important layers:</p>
<ul>
<li>A set of rain observation images,</li>
<li>The range reticle,</li>
<li>Locations,</li>
<li>Coastline.</li>
</ul>
<p>I then opened them up in MSPaint, pasting them over each other. They were all 512px square but the actual radar images have those annoying labels baked in. With this image I was able to perform a complex calculation (counting pixels by hand) to discover that Perth was at approximately <code>x=256px, y=156px</code>.</p>
<p><img src="/img/2025-08-10-mspaint.jpg" alt="MSPaint on Windows 11 opened with each layer from a rain radar pasted over showing the WA coastline near Perth, a set of labels for all the major cities, suburbs, towns, and a range reticle with markings at 50km and 100km."></p>
<p>Now if this was actual GIS data I could use <a href="https://qgis.org/">QGIS</a>/<a href="https://gdal.org/en/stable/">GDAL</a>, import the dataset and say &ldquo;create a point at my geographic coordinates then give it a 10km square buffer and extract that slice from the dataset&rdquo;. It&rsquo;s not GIS data though, but we know the image is 512 pixels square and the radar page told us it was a 128km loop, so if we treat the image as a bizarre <a href="https://docs.qgis.org/3.40/en/docs/gentle_gis_introduction/coordinate_reference_systems.html">coordinate reference system</a> then we know each pixel is ~250m and we can kinda just do the same thing. Once you <a href="/personal/2023/08/14/what-is-gis-and-why-i-love-it/">see the beauty in GIS</a> you&rsquo;ll realise how you can apply those same skills elsewhere.<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup></p>
<p>Don&rsquo;t worry, I didn&rsquo;t set up a GDAL environment just to fuck with a PNG. I set up <a href="https://pillow.readthedocs.io/en/stable/">Pillow</a> to read the image into a <a href="https://numpy.org/">NumPy</a> array from which I could extract just the pixels I wanted:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-python" data-lang="python"><span style="color:#f92672">from</span> io <span style="color:#f92672">import</span> BytesIO
<span style="color:#f92672">from</span> PIL <span style="color:#f92672">import</span> Image
<span style="color:#f92672">import</span> numpy <span style="color:#f92672">as</span> np

array <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>asarray(Image<span style="color:#f92672">.</span>open(<span style="color:#e6db74">&#34;IDR70A.T.202508091130.png&#34;</span>))<span style="color:#f92672">.</span>copy()
perth <span style="color:#f92672">=</span> array[<span style="color:#ae81ff">136</span>:<span style="color:#ae81ff">176</span>, <span style="color:#ae81ff">216</span>:<span style="color:#ae81ff">296</span>]
</code></pre></div><h2 id="how-bad-is-the-rain">How Bad Is The Rain?</h2>
<p>This variant of the radar is colour coded based on rainfall observed (rather than rain rate) so I could grab the RGBA codes using the colour dropper in the MSPaint window I and then realise I only give a shit about two values:</p>
<ul>
<li>No rain detected: 255, 255, 255, 0,</li>
<li>Light rain detected (0.2mm): 127, 254, 255, 255.</li>
</ul>
<p>Anything else I could classify as heavy rain because we&rsquo;re only talking five minute intervals for a city only had greater than <a href="http://www.bom.gov.au/jsp/ncc/cdio/weatherData/av?p_nccObsCode=136&amp;p_display_type=dailyDataFile&amp;p_startYear=2024&amp;p_c=-17105123&amp;p_stn_num=009225">10mm of rainfall on 17 days last year</a> and a dashboard that&rsquo;s mostly being used to make sure <a href="/personal/2023/02/08/mohawk-instructions/">I don&rsquo;t ruin my mohawk</a> walking to work.</p>
<h2 id="how-do-i-see-it">How Do I See It?</h2>
<p>So I&rsquo;d already created a <a href="/dev/dashboard-monitor/">dashboard monitor</a> that displays graphs in a terminal interface using <a href="https://github.com/piccolomo/plotext"><code>plotext</code></a> and I don&rsquo;t want to build a whole new thing even if I wasn&rsquo;t using it for PC monitoring anymore. Plus I still want to keep the two sine waves oscillating at different frequencies because they&rsquo;re cool and maybe add some other overlaid graphs.</p>
<p>So for low and high rain I convert the image slice (a 2D-array of colour codes) into two arrays: one each for <code>x</code> and <code>y</code> values of the relevant pixels.</p>
<p>Because of how PIL and NumPy read the arrays vs how a graph plots on axes I had to invert the <code>y</code> array (so the max value of the section subtract the actual value) so I didn&rsquo;t display the data upside down. Then I just input that as a <a href="https://github.com/piccolomo/plotext/blob/master/readme/basic.md#scatter-plot">scatterplot</a>, making sure that the graph would still go out to 80 values so the scale doesn&rsquo;t shrink when there&rsquo;s no rain out East.</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-python" data-lang="python">plot<span style="color:#f92672">.</span>scatter(low[<span style="color:#ae81ff">0</span>], low[<span style="color:#ae81ff">1</span>], marker <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;sd&#34;</span>, color <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;green+&#34;</span>, fillx<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>)
plot<span style="color:#f92672">.</span>scatter(high[<span style="color:#ae81ff">0</span>], high[<span style="color:#ae81ff">1</span>], marker <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;sd&#34;</span>, color <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;red+&#34;</span>, fillx<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>)
</code></pre></div><h2 id="how-do-i-see-direction">How Do I See Direction?</h2>
<p>Oh yeah, the great thing about the BoM rain radar is that looping animation so I know if rain is coming or going! D&rsquo;oh.</p>
<p>I don&rsquo;t really want a looping animation like the BoM site, I just want to see current rain and be able to infer it&rsquo;s passage, so let&rsquo;s grab the three previous images and do the same thing, using duller colours and smaller symbols:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-python" data-lang="python"><span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> reversed(range(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>)):
    data_low <span style="color:#f92672">=</span> rain[i][<span style="color:#ae81ff">0</span>]
    data_high <span style="color:#f92672">=</span> rain[i][<span style="color:#ae81ff">0</span>]
    plot<span style="color:#f92672">.</span>scatter(data_low[<span style="color:#ae81ff">0</span>], data_low[<span style="color:#ae81ff">1</span>], marker <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;dot&#34;</span>, color <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;green&#34;</span>, fillx<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>)
    plot<span style="color:#f92672">.</span>scatter(data_high[<span style="color:#ae81ff">0</span>], data_high[<span style="color:#ae81ff">1</span>], marker <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;dot&#34;</span>, color <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;red&#34;</span>, fillx<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>)
</code></pre></div><p>That section goes above the latest image display and you&rsquo;ll notice I iterate through so that the visible graph plots the most recent data on top (in case multiple sets have values on the same pixel coordinates).</p>
<h2 id="how-do-i-get-updates">How Do I Get Updates?</h2>
<p>This entire project came about because BoM doesn&rsquo;t have an API. There is no easy endpoint I can hit to see if there are new images. What I did notice was the radar images all have timestamps in the filename so in my update loop I check the time and if it was 7 minutes past a an image interval I make a request to what the new image would be named. If that fails I back off and check again in a minute and reset that minute counter once I get a <a href="https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#200">200 response</a> because the data may get delayed due to processing and so it&rsquo;s not exact.</p>
<p>Below is an example of how you could implement that:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-python" data-lang="python"><span style="color:#f92672">from</span> datetime <span style="color:#f92672">import</span> datetime, timedelta
<span style="color:#f92672">from</span> pytz <span style="color:#f92672">import</span> timezone
<span style="color:#f92672">import</span> time

rain_radar_delay <span style="color:#f92672">=</span> <span style="color:#ae81ff">7</span>
<span style="color:#66d9ef">while</span>:
    <span style="color:#66d9ef">if</span> (gmt<span style="color:#f92672">.</span>minute <span style="color:#f92672">+</span> rain_radar_delay) <span style="color:#f92672">%</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">&lt;</span> now<span style="color:#f92672">.</span>minute:
        gmt <span style="color:#f92672">=</span> datetime<span style="color:#f92672">.</span>now(timezone(<span style="color:#e6db74">&#39;UTC&#39;</span>))
        min_mod <span style="color:#f92672">=</span> gmt<span style="color:#f92672">.</span>minute <span style="color:#f92672">%</span> <span style="color:#ae81ff">5</span>
        gmt <span style="color:#f92672">=</span> gmt <span style="color:#f92672">-</span> timedelta(minutes<span style="color:#f92672">=</span>min_mod)
        <span style="color:#66d9ef">try</span>:
            latest <span style="color:#f92672">=</span> get_image(gmt<span style="color:#f92672">.</span>strftime(<span style="color:#e6db74">&#34;%Y%m</span><span style="color:#e6db74">%d</span><span style="color:#e6db74">%H%M&#34;</span>))
            plot <span style="color:#f92672">=</span> extract_scatter(latest) <span style="color:#75715e"># extract_scatter() where you turn image to scatterplot arrays</span>
            rain_plot<span style="color:#f92672">.</span>append(plot) 
            <span style="color:#66d9ef">del</span> rain_plot[<span style="color:#ae81ff">0</span>]
            rain_radar_delay <span style="color:#f92672">=</span> <span style="color:#ae81ff">7</span>
        <span style="color:#66d9ef">except</span>:
            rain_radar_delay <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
    plot_graph(rain_plot) <span style="color:#75715e"># plot_graph() where we run plotext</span>
    time<span style="color:#f92672">.</span>sleep(<span style="color:#ae81ff">1.5</span>)

<span style="color:#66d9ef">def</span> <span style="color:#a6e22e">get_image</span>(time: str):
    url <span style="color:#f92672">=</span> f<span style="color:#e6db74">&#34;http://www.bom.gov.au/radar/IDR70A.T.{time}.png&#34;</span> <span style="color:#75715e"># 5 Min</span>
    response <span style="color:#f92672">=</span> requests<span style="color:#f92672">.</span>get(url, headers<span style="color:#f92672">=</span>{
        <span style="color:#e6db74">&#34;Referer&#34;</span>: <span style="color:#e6db74">&#34;http://www.bom.gov.au/products/IDR70A.loop.shtml&#34;</span>,
        <span style="color:#e6db74">&#34;User-Agent&#34;</span>: <span style="color:#e6db74">&#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0&#34;</span>,
    })
    <span style="color:#66d9ef">if</span> response<span style="color:#f92672">.</span>status_code <span style="color:#f92672">!=</span> <span style="color:#ae81ff">200</span>:
        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">Exception</span>(<span style="color:#e6db74">&#34;Not successful code.&#34;</span>)
</code></pre></div><p>Once I&rsquo;ve grabbed the latest image and extracted the scatterplot, I just append it to my array of plot data and then delete the first element (the oldest plot).</p>
<h2 id="final-notes-on-project">Final Notes On Project</h2>
<p>This was a lot of fun to muck around with and has been super handy for us. It&rsquo;s great being able to see the data we want in a format and design we like that meshes nicely with our interior styling. The reason I don&rsquo;t go out and buy an IoT weather display that grabs data from a commercial provider is:</p>
<ul>
<li>I don&rsquo;t want to buy a new single purpose device, I already had that monitor laying around.</li>
<li>We want full control over the look and feel.</li>
<li><a href="https://www.watchguard.com/wgrd-ransomware/chastitylock">IoT</a> <a href="https://www.rtinsights.com/thermostat-ransomware-pen-test/">security</a> <a href="https://www2.ee.unsw.edu.au/~vijay/pubs/conf/17wisec.pdf">sucks</a> and I&rsquo;d rather have some control over what&rsquo;s in my home.</li>
<li>If the provider cuts off data supply such a device is now a paperweight.</li>
<li>I can add other non-weather feeds.</li>
<li>I can copy the data to a database so I can use it in other dashboards.</li>
<li>Commercial providers, especially global ones, aren&rsquo;t nearly as accurate for local weather.</li>
</ul>
<p>Not saying this is something everyone should have or build, but this is something that we get great utility out of, something that looks perfect with our goth-witch-tech decor. And I think if this kind of government data was made available in more accessible formats with better documentation we&rsquo;d see more creative uses and we&rsquo;d be working towards a culture that encourages people to experiment, create and interact with it in ways that works for them.</p>
<h1 id="notes-on-the-bureau">Notes On The Bureau</h1>
<p>All the stuff about my project is above, this is some colour reading about the Bureau Of Meterology; some clarifications where I&rsquo;ve made statements about their data, some criticisms of the organisation, and some warnings. Only read on if that interests you.</p>
<h2 id="preface">Preface</h2>
<p>The BoM provides a bunch of great services beyond just observations, forecasts, and warnings. They have a massive network of observation stations that have been collecting data, <a href="http://www.bom.gov.au/jsp/ncc/cdio/weatherData/av?p_nccObsCode=139&amp;p_display_type=dataFile&amp;p_startYear=&amp;p_c=&amp;p_stn_num=003030">some for over a hundred years</a>, often in places that can be thousands of kilometres from population centres. These enable us to track and understand the impacts of <a href="http://www.bom.gov.au/climate/change/">climate change</a>, to provide better forecasting and prediction of fire seasons, UV ratings (important for a country with the <a href="https://www.sciencedirect.com/science/article/pii/S2949713224000582">highest rates of skin cancer</a>), agricultural management, etc.</p>
<p>On top of that, they use the <a href="http://www.bom.gov.au/australia/charts/about/about_access.shtml">ACCESS</a> forecasting model that they built (refined from the UK Met&rsquo;s model) to be more accurate and tailored to our region in order to provide gridded forecasts at 12.5km-33km resolution across this entire sunburnt land - forecasts for every 6-hour intervals out to 10 days. A model so complex they need their own <a href="(https://www.datacenterdynamics.com/en/news/australian-bureau-of-meteorology-to-procure-disaster-recovery-hpc-system-from-hpe/)">funky little supercomputer</a> to handle it, but a system that is so crucially useful to emergency services, public safety, and all our primary industries.</p>
<p>I&rsquo;m adding the preface here because the BoM is an <em>incredibly important</em> department and one that we need to ensure continues operation even if we have <a href="https://www.theguardian.com/australia-news/commentisfree/2025/jul/23/coalition-net-zero-barnaby-joyce-michael-mccormack-sussan-ley-liberal-party-nationals">absolute fuckwits want to roll back our net zero commitments</a>, when we have repeatedly elected dickheads who <a href="https://www.theguardian.com/australia-news/2020/jan/16/there-is-no-link-the-climate-doubters-within-scott-morrisons-government">don&rsquo;t believe in climate change</a>, and <a href="https://www.abc.net.au/news/2019-04-23/lnp-senate-candidate-gerard-rennick-bom-climate-conspiracy/11036404">accuse the BoM of tampering with data</a>. We can&rsquo;t rely on other governments' meterology departments because their global models rely on our data in a reciprocal fashion (and also aren&rsquo;t as accurate for our region). We can&rsquo;t rely on commercial providers because most of them just repackage BoM data and the few that don&rsquo;t have neither the coverage nor forecasting in Australia as well as not having transparency/accountability.</p>
<p><strong>BUT.</strong></p>
<p>The BoM is also a fucking shitshow.</p>
<h2 id="no-api">No API</h2>
<p>The BoM doesn&rsquo;t provide an API to download data. There is some undocumented functionality from some BoM pages where you can force it to provide the data in something better for processing (XML/JSON) instead of HTML. If you want info and examples hit me up.</p>
<p>The thing is, BoM does actually provide free access to useful data, just in an unconventional &lsquo;retro-cool&rsquo; fashion. They have an FTP server (in The Year Of Our Lord 2025) where you can download any of <a href="http://www.bom.gov.au/catalogue/anon-ftp.shtml">their public products</a>. In fact, if you go to <code>/anon/gen/radar/</code> and <code>/anon/gen/radar_transparencies/</code> you&rsquo;ll find all those files I used in the project! I&rsquo;ve used a bunch of their other data in various jobs and projects over the years and it&rsquo;s useful, albeit annoying to add in a bunch of FTP handling libraries for one data source. And I don&rsquo;t want people to think I&rsquo;m dissing FTP because it&rsquo;s not <em>hip</em> and they should be using GraphQL or Protobuf or an LLM interface. I&rsquo;m dissing FTP because it&rsquo;s another damning indictment of their lack of care about security or infrastructure.</p>
<p>Work on implementing actual open standards APIs with proper documentation would make it accessible to a lot more people. Data could be requested based on actual schemas and not a byzantine list of product numbers under weird folder structures. Queries could be limited based on submitted locations or timeframes. Existing data that is currently only available as a web page styled after the hottest design trends of 2004 could be accessed easily through such a system and you could query historical data without having to remember each station identification number and then get annoyed because <a href="http://www.bom.gov.au/climate/data/">the form keeps resetting the metric or time period I requested</a>.</p>
<h2 id="ftp-infosecurity">FTP In(fo)security</h2>
<p>FTP is an inherently insecure protocol with no encryption. So it&rsquo;s inherently vulnerable to <a href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack">MitM attacks</a> where an attacker can read or change data on the fly. Now if I&rsquo;m accessing an anonymous public FTP server that everyone has access too and which only provides client data than does it matter if someone is reading it? From a security and privacy standpoint: <em>yes</em>. But to forestall dismissive rebuttals I want to point out that manipulation is a lot more insidious here. Rewriting transferred data isn&rsquo;t just about editing the precis to say &ldquo;over 9000 degrees tomorrow in Buttsville, NSW&rdquo; but being able to run injection attacks on XML processing, tarpitting or, sending a never-ending amount of data<sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup>. Given the BoM is also fond of providing so many datasets in neat little zips it creates a wonderful opportunity for an attacker on the path to generate <a href="https://en.wikipedia.org/wiki/Zip_bomb">zip BoMbs</a>.</p>
<p>And what about the restricted FTP products? BoM also provides a <a href="http://reg.bom.gov.au/other/charges.shtml">trove of products</a> for government departments, businesses, and researchers that cost a screaming fortune but are thankfully all protected by a username and password. Except that because FTP isn&rsquo;t encrypted anyone surfing the same wires gets free access and still the same option to manipulate the data. Considering the two protocols that are just &ldquo;please encrypt your FTP traffic&rdquo; (<a href="https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol">SFTP</a> and <a href="https://en.wikipedia.org/wiki/FTPS">FTPS</a>) are both old enough to have voted in the past three federal elections it&rsquo;s pretty poor form but also; par for course.</p>
<h2 id="regular-infosecurity">Regular In(fo)security</h2>
<p>But surely this only affects a bunch of bloody nerds doing weird stuff, not average John Smith checking the forecast! Except (again, in The Year Of Our Lord Twenty Twenty Five) the BoM also not support <a href="https://aws.amazon.com/compare/the-difference-between-https-and-http/">HTTPS</a>. This is pretty fucked in an era where <a href="https://letsencrypt.org/stats/#percent-pageloads">over 80% of all web pages</a> are viewed over HTTPS, where organisations like <a href="https://letsencrypt.org/">Let&rsquo;s Encrypt</a> provide free certificates, the <a href="https://datatracker.ietf.org/doc/html/rfc8555/">ACME protocol</a> allows for automatic verification and issuance of new certs, and where the <a href="https://www.cyber.gov.au/">Australian Cyber Security Centre</a> says all businesses and government departments must use encryption for even non-classified data over a network<a href="https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/ism/cybersecurity-guidelines/guidelines-cryptography">[1]</a><a href="https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/ism/cybersecurity-guidelines/guidelines-networking">[2]</a> and even provide provides a <a href="https://www.cyber.gov.au/resources-business-and-government/maintaining-devices-and-systems/system-hardening-and-administration/web-hardening/implementing-certificates-tls-https-and-opportunistic-tls">handy guide explaining TLS and how to set it up</a>. In fact, HTTPS is so prevalent that the campaign and extension <a href="https://www.eff.org/https-everywhere">&ldquo;HTTPS Everywhere&rdquo;</a> is discontinued because of how ubiquitous it is. Hell, the first time you visit the BoM website on a new browser you&rsquo;ll get hit with a redirect from Akamai because your browser defaults to HTTPS and you get knocked back. Somehow BoM has created it&rsquo;s own funky new counter-culture in defiance of <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security">HTTP Strict-Transport-Security (HSTS)</a> by forcibly downgrading you.</p>
<p>And maybe I&rsquo;m making a mountain out of a molehill about how dangerous these flaws are or maybe that absolutely endemic lack of cyber fucks given by the BoM is why they ended up <a href="https://www.abc.net.au/news/2016-10-12/bureau-of-meteorology-bom-cyber-hacked-by-foreign-spies/7923770">getting so thoroughly pwned by nation state actors</a>. And this is where the issue comes to a head because the BoM was not the target of this attack, they were just the vector thanks to a government that doesn&rsquo;t give a shit about IT. Because many other government-affiliated groups rely on BoM data there are reciprocal access paths betwen BoM and those who need weather data and may have valuable shit to steal such as Airservices, Geoscience Australia, Defence, Border Farce, and the Australian Federal Police<sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup>, plus various state agencies.</p>
<h2 id="attitude-to-users">Attitude To Users</h2>
<p>The fact that they <a href="http://reg.bom.gov.au/other/charges.shtml">charge access to a whole bunch of FTP products</a> is crappy for a number of reasons. Firstly, most of that data isn&rsquo;t sensitive - otherwise you wouldn&rsquo;t be putting it behind FTP - and therefore it should be free and publicly available like the rest. The BoM is just digging its heels in obstinately against the government&rsquo;s Open Data directives. Secondly, the fees being charged are bloody outrageous. It costs nearly $3000 to have them set you up an account with no access, plus $1220 every 12 months to keep your empty account. According to the BoM <a href="https://www.finance.gov.au/publications/resource-management-guides/australian-government-cost-recovery-guidelines-rmg-304">that&rsquo;s how much it costs them</a> to set you up with an FTP login and check every year that it&rsquo;s still there. Of course, that account has nothing without subscribing to products which range from a cheap $174/year for a text file with seven day forecasts for one (1) state up to $36,291/year for the ACCESS-S forecast data. I would absolutely love access to the georeferenced <a href="https://docs.unidata.ucar.edu/netcdf-c/current/">NetCDF</a> radar data but I&rsquo;m not sure I can shell out $7000/year unless they can also guarantee me a reacharound.</p>
<p>I once started trying to get a registered account for a work project and this is my third beef; it took three weeks of back and forth emails<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> before I got an invoice that obvs I couldn&rsquo;t pay by credit card. This feels like the perfect opportunity for a self-service portal so I&rsquo;m hoping that by the year 2035 they&rsquo;ll have all the fahncy modern technology like <a href="https://en.wikipedia.org/wiki/BPAY#History">BPay</a> ready so they can start adding such a system.</p>
<h2 id="the-end-game">The End Game</h2>
<p>Almost anyone I know who is adjacent to the BoM has warned me off applying for roles there because of how dysfunctional and terrible the departmental culture is, mostly from the higher levels but that is something that filters down. The role of providing actual meterological services and the infrastructure to do so is undervalued and performed by people without agency or support. But in the long run, if the department can&rsquo;t pull its head in they open up an easy avenue for the government to say it&rsquo;s more proof that doing technology internally is a waste of taxpayers' money and use it as an excuse to give four times their budget to an external consultancy who&rsquo;ll do it properly like:</p>
<ul>
<li>IBM with the <a href="https://www.abc.net.au/news/2016-11-25/ibm-to-pay-over-$30m-in-compensation-for-census-fail/8057240">2016 Census</a>,</li>
<li>Accenture with the <a href="https://helloleaders.com.au/article/accenture-secures-another-gov-contract-as-digital-transformation-costs-balloon">Department Of Health And Aged Care&rsquo;s GPMS</a>,</li>
<li>IBM with <a href="https://spectrum.ieee.org/queensland-government-bans-ibm-from-it-contracts">Queensland Health&rsquo;s payroll system</a>,</li>
<li>Deloitte with <a href="https://www.anao.gov.au/work/performance-audit/defences-procurement-and-implementation-of-the-myclearance-system">Defence&rsquo;s myClearance system</a>,</li>
<li>KPMG with the <a href="https://www.abc.net.au/news/2023-12-20/defence-data-contract-kmpg-weak-indefensible-review-finds/103247476">One Defence Data project</a>,</li>
<li>Deloitte with DWER&rsquo;s <a href="https://www.afr.com/companies/professional-services/deloitte-report-suspected-of-ai-invented-quote-from-robo-debt-case-20250825-p5mpjj">report on automated welfare compliance systems</a>.</li>
<li>And so many other projects.<sup id="fnref:6"><a href="#fn:6" class="footnote-ref" role="doc-noteref">6</a></sup></li>
</ul>
<p>Australian government needs good ICT and that means staff, funding, and an internal drive for innovation. It needs capable internal systems administrators and developers and project managers all who can provide (and are empowered to provide) that internal assistance and momentum. There will always be stuff that needs to be outsourced but those staff are vital to stopping the Big Four from forcing vendor lock-in and generating shareholder value at the expense of the taxpayer.</p>
<h2 id="final-note">Final Note</h2>
<p>In the course of writing this I discovered that BoM also offers <a href="http://www.bom.gov.au/products/IDR70A.loop.shtml">those radar images</a> as a paid subscription in their Registered FTP Service for the low, introductory price of $3695 ($2266/year thereafter). So hopefully this will be useful to someone.</p>
<h2 id="update-2025-09-01">Update 2025-09-01</h2>
<p>I&rsquo;ve added an extra link in <a href="#the-end-game">The End Game</a> covering the news that Deloitte likely used AI to generate a report it charged the taxpayer $439,000 to produce.</p>
<p>I was also informed of the $866mil &ldquo;ROBUST&rdquo; technology transformation project BoM undertook. Searching for &ldquo;BoM ROBUST&rdquo; provides a link to their media release that doesn&rsquo;t work. Thankfully you can <a href="https://media.bom.gov.au/releases/1231/robust-completion-enables-secure-stable-and-resilient-bureau-services/">find an archive of the BoM media release on Trove</a> where you discover that part of the program was to provide &ldquo;a new, secure and resilient Bureau website&rdquo;. This would explain why the media release link didn&rsquo;t work: it&rsquo;s from their not-yet-released, still-in-beta website. I mean at least it has TLS but how much of our money did they piss against the wall on consultants that couldn&rsquo;t even migrate the history from the old CMS to the new one? If the program finished mid-2024 how come this new website still isn&rsquo;t the default? How come there&rsquo;s still no APIs and outrageous data costs? Why do their <a href="https://beta.bom.gov.au/news-and-media/what-does-it-take-to-build-a-weather-radar">new media releases read like AI generated SEO slop</a>?</p>
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1" role="doc-endnote">
<p>Pronounced <a href="https://www.w3.org/TR/2003/REC-PNG-20031110/#1Scope">&ldquo;ping&rdquo;</a>. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>Off topic but as an itinerant digital hoarder I managed to dredge up an emails from 2010 when I asked in their feedback form if they would be able to add support for <code>bom.gov.au</code> instead of them only having an A record for <code>www.bom.gov.au</code>. I received a reply that between 10mil hits a day, dual load balancers, etc, it was not an easy task. Understandable response given how underfunded govt IT commonly was but ending the email with &ldquo;Meanwhile, I trust the extra 4 keystrokes aren&rsquo;t too taxing&hellip;&rdquo; felt like a dickish and unnecessary sign-off. To their credit, I received an email a year later that thanked me for my patience and announced that access via <code>bom.gov.au</code> was now possible. <a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>Or I guess if your only tool is a hammer you&rsquo;ll treat everything as a nail? <a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4" role="doc-endnote">
<p>Techniques that can be used for good if you <a href="https://algorithmic-sabotage.github.io/asrg/posts/sabot-in-the-age-of-ai/#selected-tools-and-frameworks">want to fuck AI crawlers</a> on your site. <a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5" role="doc-endnote">
<p>A federal department that was created for one purpose: <a href="https://en.wikipedia.org/wiki/Billy_Hughes_egg-throwing_incident#Warwick_speech">to stop people throwing eggs at the prime minister</a>. <a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:6" role="doc-endnote">
<p>Okay I&rsquo;m going to stop going through <a href="https://www.tenders.gov.au/Search/CnAdvancedSearch?Type=Cn&amp;AgencyStatus=0&amp;KeywordTypeSearch=AllWord&amp;DateType=Publish%20Date&amp;ValueFrom=50000000">AusTender contract amendments</a> and <a href="https://www.anao.gov.au/pubs">ANAO reports</a> before it convinces me to go for a doctorate in mathematics and move to a log cabin to write <em>&ldquo;Consultancy Society and Its Future&rdquo;</em>. <a href="#fnref:6" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</section>
]]></content>
  </entry>
  
  <entry>
    <id>https://az.id.au/dev/wind-particle-layer-in-deckgl-9.x/</id>
    <title type="html"><![CDATA[Wind Particle Layer in Deck.GL 9.x]]></title>
    <link rel="alternate" href="https://az.id.au/dev/wind-particle-layer-in-deckgl-9.x/"/>
    <updated>2025-05-04T00:00:00Z</updated>
    <author><name>Az</name></author>
    <content type="html"><![CDATA[<p><em>Note: I&rsquo;m working on some other posts at the moment but felt I&rsquo;d left the <code>dev</code> section of my blog alone for too long. So here&rsquo;s a little interim post that tackles an issue I had lately.</em></p>
<p>Everyone loves fahncy web maps, stuff that looks cool and makes you feel like computers are neat. And there&rsquo;s a popular type of layer that shows wind speeds as a gorgeously animated particle effect. Probably the most famous example for my fellow Aussies is <a href="https://bushfire.io/">bushfire.io</a>. Damn, doesn&rsquo;t that look cool as hell?</p>
<p>There&rsquo;s a few <a href="https://blog.mapbox.com/how-i-built-a-wind-map-with-webgl-b63022b5537f">tutorials</a> and <a href="https://github.com/junhaotong/deckgl-particle">libraries</a> online so you can learn how to implement it, I&rsquo;ve previously used that second link to do it in JavaScript via <a href="https://deck.gl/">deck.gl</a>; the neat-o modern web mapping library that takes advantage of your device&rsquo;s GPU through the <a href="https://www.khronos.org/webgl/">WebGL2 API</a>. *<em>finger to earpiece</em>* I&rsquo;m just getting word from our <a href="https://scorpinc.solutions/">Chronomancy division</a> that my information is out of date. While that was correct for deck.gl and <a href="https://luma.gl/">luma.gl</a> (the graphics library powering deck.gl) throughout version 8.x, the <a href="https://luma.gl/docs/legacy/porting-guide/#v9-api-design-background">major bump to version 9.x</a> from over a year ago is focused on the <a href="https://www.w3.org/TR/webgpu/">WebGPU</a> API. Subsequently there is a heap of breaking changes with a lot of version 8.x plugins or layers and definitely so for any where you write your own shaders.</p>
<p>Unfortunately a project I was working on needed the upgrade to deck.gl and I got absolutely lost getting the particle layer up to spec. If you&rsquo;re in a similar spot, this might help you implement a wind particle layer in deck.gl 9.1. It&rsquo;ll also let you do the entire thing in <a href="https://www.typescriptlang.org/">TypeScript</a> so your linter won&rsquo;t keep cracking the shits at you.</p>
<p><em>Please note that this requires at least deck.gl <strong>9.1</strong> - there&rsquo;s issues in 9.0 where it won&rsquo;t compile the TypeScript using the below method.</em></p>
<h2 id="backend">Backend</h2>
<p>I&rsquo;m gonna gloss over this because you would need this for any existing 8.x implementation. But you need ~something~ (image, binary file, big-ass array) that represents the wind speed for every point on the globe. For my implementation of a particle layer I use a PNG where the red channel represents horizontal wind speed and the green represents vertical wind speed. Each pixel thus represents a coordinate on the Earth&rsquo;s surface. By taking the horizontal and vertical wind speed you can work out the velocity (ie: direction and speed) which is how we get the particle layer working.</p>
<p>Where do you get such an image? You can grab it from <a href="https://www.ncei.noaa.gov/products/weather-climate-models/global-forecast">NOAA&rsquo;s GFS (Global Forecast System)</a>. Grab the relevant forecast time and oh wow it&rsquo;s probably some big-ass NetCDF file? Don&rsquo;t worry, you can use the <code>gdal_translate</code> utility to pull out what you want like so:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-sh" data-lang="sh">gdal_translate -b <span style="color:#ae81ff">11</span> -b <span style="color:#ae81ff">12</span> -b <span style="color:#ae81ff">12</span> -ot Byte -scale -128 <span style="color:#ae81ff">127</span> <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">255</span> gfs.t12z.pgrb2.1p00.f000 output.png
</code></pre></div><p>That will grab bands 11 and 12 (horizontal/vertical wind speed) with each being limited to a value between 0-255 (<code>-ot Byte -scale -128 127 0 255</code>) and give you a sweet PNG file. Make sure this is accessible by your web map (don&rsquo;t forgets <a href="https://en.wikipedia.org/wiki/Inferno_(Dante)#Fifth_Circle_(Wrath)">CORS</a>!).</p>
<h2 id="caveats">Caveats</h2>
<p><em><strong>This is not for safety.</strong></em> Heads up that GFS is forecast - if you wanted to do latest wind <em>observations</em> then you&rsquo;ll need to find another source. Note that the GFS only goes down to about 0.25 of a degree so I would say it&rsquo;s a good model for a pretty animation or a rough idea of wind speed but I would not use this layer in a safety context.</p>
<p><em><strong>This is an intro example.</strong></em> This version doesn&rsquo;t have all the funky features you see of some of the other particle layers nor does it allow you to use a globe model but is designed as a starting point to work from. If you want something fahncier with colours or adjustable speeds then hell yeah brother, go for it. If you want me to add those features or toggles to the examples you can contact me via an existing channel to find out my consulting rates. I offer discounts to charities and organisations doing ethical work and will send you a photo of a gaping anus (possibly even mine) if you represent a defence company.</p>
<h2 id="before-the-layer">Before The Layer</h2>
<p>You&rsquo;ll need to make sure you import the image <em>before</em> instantiating the particle layer. Here&rsquo;s a little function that&rsquo;ll import the PNG image to a temporary canvas and get the raw pixel data for it:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">loadImage</span>(<span style="color:#a6e22e">url</span>: <span style="color:#66d9ef">string</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">ImageData</span>&gt; {
  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">img</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Image</span>();
  <span style="color:#a6e22e">img</span>.<span style="color:#a6e22e">src</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">url</span>;
  <span style="color:#a6e22e">img</span>.<span style="color:#a6e22e">crossOrigin</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;anonymous&#34;</span>;
  <span style="color:#66d9ef">try</span> {
    <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">img</span>.<span style="color:#a6e22e">decode</span>();
  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">e</span>) {
    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">`Image </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">url</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> can&#39;t be decoded.`</span>);
  }
  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">canvas</span> <span style="color:#f92672">=</span> document.<span style="color:#a6e22e">createElement</span>(<span style="color:#e6db74">&#34;canvas&#34;</span>);
  <span style="color:#a6e22e">canvas</span>.<span style="color:#a6e22e">width</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">img</span>.<span style="color:#a6e22e">width</span>;
  <span style="color:#a6e22e">canvas</span>.<span style="color:#a6e22e">height</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">img</span>.<span style="color:#a6e22e">height</span>;
  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">canvas2d</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">canvas</span>.<span style="color:#a6e22e">getContext</span>(<span style="color:#e6db74">&#34;2d&#34;</span>);
  <span style="color:#a6e22e">canvas2d</span>.<span style="color:#a6e22e">drawImage</span>(<span style="color:#a6e22e">img</span>, <span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>);
  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">canvas2d</span>.<span style="color:#a6e22e">getImageData</span>(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>, <span style="color:#a6e22e">canvas</span>.<span style="color:#a6e22e">width</span>, <span style="color:#a6e22e">canvas</span>.<span style="color:#a6e22e">height</span>);
}
</code></pre></div><p>If you have an alternate form of wind data, you&rsquo;ll need to make changes to how this works to suit your needs.</p>
<h2 id="the-particlelayer-class">The <code>ParticleLayer</code> Class</h2>
<p>If you have an existing deck.gl 8.x implementation or have used the examples I linked above then a lot of the code for the <code>ParticleLayer</code> class will be the same. What I&rsquo;m going to do is run through the major changes rather than build the whole thing, however I will include links to a full example at the end of the post.</p>
<h3 id="typing">Typing</h3>
<p>This is like any <a href="https://en.wikipedia.org/wiki/Purgatorio#First_terrace_(Pride)">JavaScript to TypeScript conversion</a>. Gotta add those types. But like every such conversion you suddenly realise how much you just used the flow of the code to assume what type would be there. Sure strict typing prevents errors where you did that incorrectly but it also means a lot more type time making sure everything is initialised and hinted from the outset and checks to ensure that something is not undefined (even if it wouldn&rsquo;t be in actual use)<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>.</p>
<p>For our module this will hit us in two places. Firstly for my implementation I&rsquo;m using a <a href="https://luma.gl/docs/api-reference/shadertools/shader-module/"><code>ShaderModule</code></a> for the <code>uniform</code> declarations. Because our TypeScript and <a href="https://www.khronos.org/opengles/">GLSL</a> is typed we need to make sure we define not just the types used by our regular TS code but also define them on the GPU. So instead of just slamming a bunch of uniforms into our <code>transform.run({uniforms})</code> that we did in the carefree days of yore (1-6 years ago), we need to have something like this:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#75715e">// Shader Module
</span><span style="color:#75715e"></span>
<span style="color:#66d9ef">export</span> <span style="color:#66d9ef">type</span> <span style="color:#a6e22e">UniformProps</span> <span style="color:#f92672">=</span> {
  <span style="color:#a6e22e">numParticles</span>: <span style="color:#66d9ef">number</span>;
  <span style="color:#a6e22e">maxAge</span>: <span style="color:#66d9ef">number</span>;
  <span style="color:#a6e22e">speedFactor</span>: <span style="color:#66d9ef">number</span>;
  <span style="color:#a6e22e">time</span>: <span style="color:#66d9ef">number</span>;
  <span style="color:#a6e22e">seed</span>: <span style="color:#66d9ef">number</span>;
  <span style="color:#a6e22e">viewportBounds</span>: <span style="color:#66d9ef">number</span>[];
  <span style="color:#a6e22e">viewportZoomChangeFactor</span>: <span style="color:#66d9ef">number</span>;
  <span style="color:#a6e22e">imageUnscale</span>: <span style="color:#66d9ef">number</span>[];
  <span style="color:#a6e22e">bounds</span>: <span style="color:#66d9ef">number</span>[];
  <span style="color:#a6e22e">bitmapTexture</span>: <span style="color:#66d9ef">Texture</span>;
};

<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">uniformBlock</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#960050;background-color:#1e0010">\</span><span style="color:#e6db74">
</span><span style="color:#e6db74">uniform bitmapUniforms {
</span><span style="color:#e6db74">  float numParticles;
</span><span style="color:#e6db74">  float maxAge;
</span><span style="color:#e6db74">  float speedFactor;
</span><span style="color:#e6db74">  float time;
</span><span style="color:#e6db74">  float seed;
</span><span style="color:#e6db74">  vec4 viewportBounds;
</span><span style="color:#e6db74">  float viewportZoomChangeFactor;
</span><span style="color:#e6db74">  vec2 imageUnscale;
</span><span style="color:#e6db74">  vec4 bounds;
</span><span style="color:#e6db74">} bitmap;
</span><span style="color:#e6db74">`</span>;

<span style="color:#66d9ef">export</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bitmapUniforms</span> <span style="color:#f92672">=</span> {
  <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;bitmap&#34;</span>,
  <span style="color:#a6e22e">vs</span>: <span style="color:#66d9ef">uniformBlock</span>,
  <span style="color:#a6e22e">fs</span>: <span style="color:#66d9ef">uniformBlock</span>,
  <span style="color:#a6e22e">uniformTypes</span><span style="color:#f92672">:</span> {
    <span style="color:#a6e22e">numParticles</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;f32&#34;</span>,
    <span style="color:#a6e22e">maxAge</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;f32&#34;</span>,
    <span style="color:#a6e22e">speedFactor</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;f32&#34;</span>,
    <span style="color:#a6e22e">time</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;f32&#34;</span>,
    <span style="color:#a6e22e">seed</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;f32&#34;</span>,
    <span style="color:#75715e">// @ts-ignore
</span><span style="color:#75715e"></span>    <span style="color:#a6e22e">viewportBounds</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;vec4&lt;f32&gt;&#34;</span>,
    <span style="color:#a6e22e">viewportZoomChangeFactor</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;f32&#34;</span>,
    <span style="color:#a6e22e">imageUnscale</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;vec2&lt;f32&gt;&#34;</span>,
    <span style="color:#a6e22e">bounds</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;vec4&lt;f32&gt;&#34;</span>,
  },
} <span style="color:#66d9ef">as</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">satisfies</span> <span style="color:#a6e22e">ShaderModule</span>&lt;<span style="color:#f92672">UniformProps</span>&gt;;
</code></pre></div><p>A bunch of boilerplate but it ensures when we run the transform we&rsquo;ve got the correct data (or at least types, not on me if you assign the wrong values) when we&rsquo;re ready to run the transform.</p>
<p>The other big part is making sure the state of our <code>ParticleLayer</code> class is properly set up. So right after the class declaration we&rsquo;ll drop this in:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">default</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ParticleLayer</span><span style="color:#f92672">&lt;</span>
  <span style="color:#a6e22e">D</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">any</span>,
  <span style="color:#a6e22e">ExtraPropsT</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">ParticleLayerProps</span>&lt;<span style="color:#f92672">D</span>&gt;
<span style="color:#f92672">&gt;</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">LineLayer</span>&lt;<span style="color:#f92672">D</span><span style="color:#960050;background-color:#1e0010">,</span> <span style="color:#a6e22e">ExtraPropsT</span> <span style="color:#960050;background-color:#1e0010">&amp;</span> <span style="color:#a6e22e">ParticleLayerProps</span><span style="color:#960050;background-color:#1e0010">&lt;</span><span style="color:#a6e22e">D</span>&gt;<span style="color:#f92672">&gt;</span> {
  <span style="color:#a6e22e">state</span><span style="color:#f92672">!:</span> {
    <span style="color:#a6e22e">model?</span>: <span style="color:#66d9ef">Model</span>;
    <span style="color:#a6e22e">initialized</span>: <span style="color:#66d9ef">boolean</span>;
    <span style="color:#a6e22e">numInstances</span>: <span style="color:#66d9ef">number</span>;
    <span style="color:#a6e22e">numAgedInstances</span>: <span style="color:#66d9ef">number</span>;
    <span style="color:#a6e22e">sourcePositions</span>: <span style="color:#66d9ef">Buffer</span>;
    <span style="color:#a6e22e">targetPositions</span>: <span style="color:#66d9ef">Buffer</span>;
    <span style="color:#a6e22e">sourcePositions64Low</span>: <span style="color:#66d9ef">Float32Array</span>;
    <span style="color:#a6e22e">targetPositions64Low</span>: <span style="color:#66d9ef">Float32Array</span>;
    <span style="color:#a6e22e">colors</span>: <span style="color:#66d9ef">Buffer</span>;
    <span style="color:#a6e22e">widths</span>: <span style="color:#66d9ef">Float32Array</span>;
    <span style="color:#a6e22e">transform</span>: <span style="color:#66d9ef">BufferTransform</span>;
    <span style="color:#a6e22e">previousViewportZoom</span>: <span style="color:#66d9ef">number</span>;
    <span style="color:#a6e22e">previousTime</span>: <span style="color:#66d9ef">number</span>;
    <span style="color:#a6e22e">texture</span>: <span style="color:#66d9ef">Texture</span>;
    <span style="color:#a6e22e">stepRequested</span>: <span style="color:#66d9ef">boolean</span>;
  };
}
</code></pre></div><p>A bunch of the properties may already be familiar. You&rsquo;ll also want to make sure you&rsquo;ve typed your <code>ParticleLayerProps</code> as an extension of <code>LineLayerProps</code> and done due dilligence with any default props. That&rsquo;s an exercise left for the reader. Unless you just want to copy what I&rsquo;ve got at the bottom, that&rsquo;s valid too.</p>
<p>Typing done. Next I&rsquo;ll run through the major functions you&rsquo;ll have used in the 8.x implementation, in each case focusing on what makes our variant different.</p>
<h3 id="getshaders"><code>getShaders()</code></h3>
<p>This is the same. The only thing I&rsquo;ll need you to change is mentions of <code>varying</code>. So when you&rsquo;re injecting at the start of the vertex shader you&rsquo;ll want to do:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-glsl" data-lang="glsl"><span style="color:#66d9ef">out</span> <span style="color:#66d9ef">float</span> drop;
</code></pre></div><p>And the accompanying fragment shader will have:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-glsl" data-lang="glsl"><span style="color:#66d9ef">in</span> <span style="color:#66d9ef">float</span> drop;
</code></pre></div><p>This represents the direction of each - from the vertex shader it&rsquo;ll be an output which will become an input into the fragment shader.</p>
<h3 id="initialisestate"><code>initialiseState()</code></h3>
<p>Unfortunately because of the seppo cultural hegemony we can&rsquo;t fix the obvious mistakes of &ldquo;initalize&rdquo; and &ldquo;color&rdquo; thanks to so many being references to the class we&rsquo;re extending from and integrations in existing modules but that&rsquo;s what we get when the lingua franca was designed by people who popularised horse dewormer as an antiviral.</p>
<p>What we can do is after we remove some of the <code>LineLayer</code> attributes as in the 8.x version we need to re-instance the ones we will use and also describe their buffer layout on the GPU:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#a6e22e">initializeState() {</span>
  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">color</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">props</span>.<span style="color:#a6e22e">color</span>;
  <span style="color:#66d9ef">super</span>.<span style="color:#a6e22e">initializeState</span>();
  <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">_setupTransformFeedback</span>();
  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">attributeManager</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">getAttributeManager</span>();
  <span style="color:#a6e22e">attributeManager</span><span style="color:#f92672">!</span>.<span style="color:#a6e22e">remove</span>([
    <span style="color:#e6db74">&#34;instanceSourcePositions&#34;</span>,
    <span style="color:#e6db74">&#34;instanceTargetPositions&#34;</span>,
    <span style="color:#e6db74">&#34;instanceColors&#34;</span>,
    <span style="color:#e6db74">&#34;instanceWidths&#34;</span>,
  ]);
  <span style="color:#a6e22e">attributeManager</span><span style="color:#f92672">!</span>.<span style="color:#a6e22e">addInstanced</span>({
    <span style="color:#a6e22e">instanceSourcePositions</span><span style="color:#f92672">:</span> {
      <span style="color:#a6e22e">size</span>: <span style="color:#66d9ef">3</span>,
      <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;float32&#34;</span>,
      <span style="color:#a6e22e">noAlloc</span><span style="color:#f92672">:</span> <span style="color:#f92672">!</span><span style="color:#ae81ff">0</span>,
    },
    <span style="color:#a6e22e">instanceTargetPositions</span><span style="color:#f92672">:</span> {
      <span style="color:#a6e22e">size</span>: <span style="color:#66d9ef">3</span>,
      <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;float32&#34;</span>,
      <span style="color:#a6e22e">noAlloc</span><span style="color:#f92672">:</span> <span style="color:#f92672">!</span><span style="color:#ae81ff">0</span>,
    },
    <span style="color:#a6e22e">instanceColors</span><span style="color:#f92672">:</span> {
      <span style="color:#a6e22e">size</span>: <span style="color:#66d9ef">4</span>,
      <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;float32&#34;</span>,
      <span style="color:#a6e22e">noAlloc</span><span style="color:#f92672">:</span> <span style="color:#f92672">!</span><span style="color:#ae81ff">0</span>,
      <span style="color:#a6e22e">defaultValue</span><span style="color:#f92672">:</span> [<span style="color:#a6e22e">color</span>[<span style="color:#ae81ff">0</span>], <span style="color:#a6e22e">color</span>[<span style="color:#ae81ff">1</span>], <span style="color:#a6e22e">color</span>[<span style="color:#ae81ff">2</span>], <span style="color:#a6e22e">color</span>[<span style="color:#ae81ff">3</span>]],
    },
  });
}
</code></pre></div><p>Easy.</p>
<h3 id="_setuptransformfeedback"><code>_setupTransformFeedback()</code></h3>
<p>Here&rsquo;s where we prepare the <a href="https://luma.gl/docs/api-reference/engine/compute/buffer-transform/"><code>BufferTransform</code></a>, what we knew in 8.x as simply the <a href="https://github.com/visgl/luma.gl/blob/v8.5.20/docs/developer-guide/transform-feedback.md"><code>Transform</code></a>. This function should only be run when it first loads and when you change any props, layers, etc. A lot of it should be familiar to your existing implementation but I will note that deck.gl and luma.gl 9.x use a slightly different syntax for creating Buffers as they&rsquo;re attaching them to the device. You can see the old/new versions below:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#75715e">// Old
</span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">gl</span> } <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">context</span>;
<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">sourcePositions</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Buffer</span>(
  <span style="color:#a6e22e">gl</span>,
  <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Float32Array</span>(<span style="color:#a6e22e">numInstances</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">3</span>)
);
</code></pre></div><div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#75715e">// New
</span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">sourcePositions</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">device</span>.<span style="color:#a6e22e">createBuffer</span>(
  <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Float32Array</span>(<span style="color:#a6e22e">numInstances</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">3</span>)
);
</code></pre></div><p>Next up is the actual <del>Transform</del> BufferTransform. There&rsquo;s a few syntactical changes here which align more to how it&rsquo;s used as well as some extra fields to make sure we have the correct layout and can inform the device how the buffers will be used:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">transform</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">BufferTransform</span>(<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">device</span>, {
  <span style="color:#a6e22e">attributes</span><span style="color:#f92672">:</span> {
    <span style="color:#a6e22e">sourcePosition</span>: <span style="color:#66d9ef">sourcePositions</span>,
  },
  <span style="color:#a6e22e">bufferLayout</span><span style="color:#f92672">:</span> [
    {
      <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;sourcePosition&#34;</span>,
      <span style="color:#a6e22e">format</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;float32x3&#34;</span>,
    },
  ],
  <span style="color:#a6e22e">feedbackBuffers</span><span style="color:#f92672">:</span> {
    <span style="color:#a6e22e">targetPosition</span>: <span style="color:#66d9ef">targetPositions</span>,
  },
  <span style="color:#a6e22e">vs</span>: <span style="color:#66d9ef">shader</span>,
  <span style="color:#a6e22e">varyings</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#34;targetPosition&#34;</span>],
  <span style="color:#66d9ef">module</span><span style="color:#a6e22e">s</span><span style="color:#f92672">:</span> [<span style="color:#a6e22e">bitmapUniforms</span>],
  <span style="color:#a6e22e">vertexCount</span>: <span style="color:#66d9ef">numParticles</span>,
});
</code></pre></div><p>Main differences:</p>
<ul>
<li><code>sourceBuffers</code> -&gt; <code>attributes</code>.</li>
<li>Specifying the buffer layout for the <code>attributes</code>.</li>
<li>Explicitly stating that the <code>targetPosition</code> in our feedback buffer is a <code>varying</code>.</li>
<li>Adding our module! That&rsquo;ll come up again later.</li>
<li><code>elementCount</code> -&gt; <code>vertexCount</code>.</li>
</ul>
<h3 id="draw"><code>draw()</code></h3>
<p>We&rsquo;ll continue running the standard model attached to <code>ParticleLayer</code>/<code>LineLayer</code>, with the main change is instead of solely using <code>model.setAttributes()</code> we&rsquo;ll set some of them as constant attributes:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#a6e22e">model</span>.<span style="color:#a6e22e">setAttributes</span>({
  <span style="color:#a6e22e">instanceSourcePositions</span>: <span style="color:#66d9ef">sourcePositions</span>,
  <span style="color:#a6e22e">instanceTargetPositions</span>: <span style="color:#66d9ef">targetPositions</span>,
  <span style="color:#a6e22e">instanceColors</span>: <span style="color:#66d9ef">colors</span>,
});
<span style="color:#a6e22e">model</span>.<span style="color:#a6e22e">setConstantAttributes</span>({
  <span style="color:#a6e22e">instanceSourcePositions64Low</span>: <span style="color:#66d9ef">sourcePositions64Low</span>,
  <span style="color:#a6e22e">instanceTargetPositions64Low</span>: <span style="color:#66d9ef">targetPositions64Low</span>,
  <span style="color:#a6e22e">instanceWidths</span>: <span style="color:#66d9ef">widths</span>,
});
</code></pre></div><h3 id="_runtransformfeedback"><code>_runTransformFeedback()</code></h3>
<p>Cool, now we&rsquo;re mucking around with our <code>BufferTransform</code> so we&rsquo;ll see more changes.</p>
<p>First up is when we run the transform. We need to set the uniforms using our earlier typing <em>(haha, foreshadowing)</em> and then make them inputs to the shader on the model of our <code>BufferTransform</code> (which is different to the model on our layer and you can see why this project drove me insane before I started getting results). I&rsquo;m also setting a few options on the transform that will help prevent flickering when panning/zooming the map:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">modUniforms</span>: <span style="color:#66d9ef">UniformProps</span> <span style="color:#f92672">=</span> {
  <span style="color:#a6e22e">bitmapTexture</span>: <span style="color:#66d9ef">texture</span>,
  <span style="color:#a6e22e">viewportBounds</span>: <span style="color:#66d9ef">viewportBounds</span> <span style="color:#f92672">||</span> [<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>],
  <span style="color:#a6e22e">viewportZoomChangeFactor</span>: <span style="color:#66d9ef">viewportZoomChangeFactor</span> <span style="color:#f92672">||</span> <span style="color:#ae81ff">0</span>,
  <span style="color:#a6e22e">imageUnscale</span>: <span style="color:#66d9ef">imageUnscale</span> <span style="color:#f92672">||</span> [<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>],
  <span style="color:#a6e22e">bounds</span>,
  <span style="color:#a6e22e">numParticles</span>,
  <span style="color:#a6e22e">maxAge</span>,
  <span style="color:#a6e22e">speedFactor</span>: <span style="color:#66d9ef">currentSpeedFactor</span>,
  <span style="color:#a6e22e">time</span>,
  <span style="color:#a6e22e">seed</span>: <span style="color:#66d9ef">Math.random</span>(),
};
<span style="color:#a6e22e">transform</span>.<span style="color:#a6e22e">model</span>.<span style="color:#a6e22e">shaderInputs</span>.<span style="color:#a6e22e">setProps</span>({ <span style="color:#a6e22e">bitmap</span>: <span style="color:#66d9ef">modUniforms</span> });
<span style="color:#a6e22e">transform</span>.<span style="color:#a6e22e">run</span>({
  <span style="color:#a6e22e">clearColor</span>: <span style="color:#66d9ef">false</span>,
  <span style="color:#a6e22e">clearDepth</span>: <span style="color:#66d9ef">false</span>,
  <span style="color:#a6e22e">clearStencil</span>: <span style="color:#66d9ef">false</span>,
  <span style="color:#a6e22e">depthReadOnly</span>: <span style="color:#66d9ef">true</span>,
  <span style="color:#a6e22e">stencilReadOnly</span>: <span style="color:#66d9ef">true</span>,
});
</code></pre></div><p>Here you&rsquo;ll notice I&rsquo;ve stripped out all the stuff to check whether it&rsquo;s a flat map or a globe. This is because I refuse to teach you — my loving children — that the Earth is round. Or because I just wanted to get an example working so I could post this and you can do that part yourself<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>.</p>
<p>Now usually we&rsquo;d swap the buffers around, shifting the data slightly so we can &ldquo;age out&rdquo; the particles over 25 before we attend next year&rsquo;s Oscars. But for the 9.x version of luma.gl things change because our buffers are on the GPU. We need to use a <a href="https://luma.gl/docs/api-reference/core/resources/command-encoder">CommandEncoder</a> to muck around with our memory:</p>
<div class="highlight"><pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-typescript" data-lang="typescript"><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">encoder</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">device</span>.<span style="color:#a6e22e">createCommandEncoder</span>();
<span style="color:#a6e22e">encoder</span>.<span style="color:#a6e22e">copyBufferToBuffer</span>({
  <span style="color:#a6e22e">sourceBuffer</span>: <span style="color:#66d9ef">sourcePositions</span>,
  <span style="color:#a6e22e">sourceOffset</span>: <span style="color:#66d9ef">0</span>,
  <span style="color:#a6e22e">destinationBuffer</span>: <span style="color:#66d9ef">targetPositions</span>,
  <span style="color:#a6e22e">destinationOffset</span>: <span style="color:#66d9ef">numParticles</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">4</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">3</span>,
  <span style="color:#a6e22e">size</span>: <span style="color:#66d9ef">numAgedInstances</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">4</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">3</span>,
});
<span style="color:#a6e22e">encoder</span>.<span style="color:#a6e22e">finish</span>();
<span style="color:#a6e22e">encoder</span>.<span style="color:#a6e22e">destroy</span>();

<span style="color:#75715e">// Swap the buffers.
</span><span style="color:#75715e"></span><span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">state</span>.<span style="color:#a6e22e">sourcePositions</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">targetPositions</span>;
<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">state</span>.<span style="color:#a6e22e">targetPositions</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">sourcePositions</span>;
<span style="color:#a6e22e">transform</span>.<span style="color:#a6e22e">model</span>.<span style="color:#a6e22e">setAttributes</span>({
  <span style="color:#a6e22e">sourcePosition</span>: <span style="color:#66d9ef">targetPositions</span>,
});
<span style="color:#a6e22e">transform</span>.<span style="color:#a6e22e">transformFeedback</span>.<span style="color:#a6e22e">setBuffers</span>({
  <span style="color:#a6e22e">targetPosition</span>: <span style="color:#66d9ef">sourcePositions</span>,
});
</code></pre></div><p>That should be everything for our <code>ParticleLayer</code> class. Only one major step left.</p>
<h2 id="glsl-shader">GLSL Shader</h2>
<p>First off you can just strip out all those uniforms from your old one, we now have them in our typed version above which will be automatically prepended to the shader. Whenever you&rsquo;d usually reference one of those uniforms, just preface it with the module name: <code>bitmap.numParticles</code>.</p>
<p>The next important thing is to grab a cold one and spend some time having wheely-chair races around the office. Job done.</p>
<h2 id="wait-really">Wait Really?</h2>
<p>Yeah. You can find the code for a working example on <a href="https://gitlab.judges119.me/judges119/deck-particle">GitLab</a> or <a href="https://github.com/judges119/deck-particle">GitHub</a>. You can also <a href="/demo/deck-particle-layer/">check out a demo</a>.</p>
<blockquote>
<p>Shout out to <a href="https://twitter.com/King_Owl">@King_Owl</a> for doing an initial read/tone check and <a href="https://scorpinc.social/@shmouflon">@shmouflon</a> for corrections (first post where I haven&rsquo;t messed up it&rsquo;s/its!). The views expressed in this post reflect the views held by their employers and also represent endorsements.</p>
</blockquote>
<p>Honestly I had a lot of fun deep diving into WebGL, WebGPU<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>, as well as graphics shaders which I hadn&rsquo;t previously much practical experience. This experience really taught me so much about B2B sales.</p>
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1" role="doc-endnote">
<p>It also helps any future extensions or maintainers not accidentally go against those expected uses. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>Or request a quote. <a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>I know I&rsquo;ve got a few conceptual mistakes in here about how much is done on the GPU vs CPU, but hopefully the small mistakes are understandable and do not distract from the working code and correct opinions that you know and love me for. <a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</section>
]]></content>
  </entry>
  
  <entry>
    <id>https://az.id.au/personal/2025/04/23/buying-music/</id>
    <title type="html"><![CDATA[Buying Music]]></title>
    <link rel="alternate" href="https://az.id.au/personal/2025/04/23/buying-music/"/>
    <updated>2025-04-23T00:00:00Z</updated>
    <author><name>Az</name></author>
    <content type="html"><![CDATA[<p>Musicians have been in a struggle arc for some time, and streaming services are somewhat to blame for the modern form of it. <a href="https://lasallefalconer.com/2023/02/why-spotifys-pay-structure-is-unfair-to-artists/">Spotify pays artists jack shit</a> but this has lead to a lot of people moving to services like Apple Music, YouTube Music, Tidal, etc and crowing that it is the superior moral choice when in reality <a href="https://virpp.com/hello/music-streaming-payouts-comparison-a-guide-for-musicians/">those platforms suck almost as much</a>. Music streaming platforms are another example of how <a href="https://www.wheresyoured.at/peakai/">tech capitalism screws over independent creators in order to throw cash into the gaping maws of investors</a>.</p>
<p>Sometimes streaming platforms have features worth the subscription price but if you really like music and if you really like art then support it in the traditional ways that actually provide them with some money:</p>
<ul>
<li>Buy albums,</li>
<li>Go to concerts,</li>
<li>Buy merch.</li>
</ul>
<h2 id="the-case-for-streaming">The Case For Streaming</h2>
<p>I still have my Spotify subscription - I&rsquo;m not going to cancel that because I find it eminently useful for <em>music discovery</em>. When I&rsquo;m playing a song or album I can hit the <a href="https://en.wikipedia.org/wiki/Hamburger_button#Kebab_button">kebab</a>, press &ldquo;<a href="https://open.spotify.com/playlist/37i9dQZF1E8O46mU2Q33af">Go to song radio</a>&rdquo;, and know that I&rsquo;ll be immediately on a journey of finding similar tunes. When you&rsquo;re listening to niche genres it can only be so effective but it will also likely find you something new and different that you also vibe with. You can check out the artist, play+like a few more songs (in the vain hope of training a black box algorithm) and keep hitting &ldquo;<a href="https://open.spotify.com/playlist/37i9dQZF1E8AKaW4HVIGOI?si=e830731a498c443c">Go to song radio</a>&rdquo; on a little journey of aural discovery.</p>
<p>The more you check out the radios of songs you haven&rsquo;t heard before, the further you go down that pipeline, the more you discover new genres and collections of artists you haven&rsquo;t heard before, the more you find music that touches you or gets you grooving in ways you hadn&rsquo;t really done before, and for that alone I&rsquo;m happy to keep paying Spotify.</p>
<p>Alongside that, Spotify does a pretty good job with it&rsquo;s &ldquo;Daily Mix&rdquo; series of playlists - each day it generates a new collection of six (6) or so playlists based on genres you&rsquo;ve been listening too lately and they make for great listening during the work day - it&rsquo;s a tailored feed for you without the <a href="https://www.wired.com/story/spotify-ai-dj/">shitty AI voice DJ</a> or the really hit&rsquo;n&rsquo;miss nature of some of their other attempts like Smart Shuffle. While it builds the playlists based off artists and songs you have had in rotation it also tries to throw in similar stuff you may not have heard which also helps on the discovery angle. This is probably because Spotify put in so much work on isolating and manually working out the types of music people listened too&hellip; <a href="https://www.digitalmusicnews.com/2024/01/19/spotify-daylist-is-blowing-up-too-bad-the-creator-was-laid-off/">Before it fired the people in charge of that</a>.</p>
<p>This level of discovery, all in the same place that I&rsquo;m listening to music, really makes it worth the sub for me. I know that by liking and listening to songs it&rsquo;s probably also helping the artists get more notice from The Algorithm™ but it&rsquo;s not a fair trade-off for how little they earn from it which leads me to one of my goals for 2024.</p>
<h2 id="2024---the-year-of-buying-music">2024 - The Year Of Buying Music</h2>
<p>I decided to buy an album (or an equivalent value in EPs/singles) each week to support artists that I loved listening too. Every time I found banger songs or albums or artists I&rsquo;d add them to a list in my <a href="https://obsidian.md/">Obsidian</a> setup and every Sunday I&rsquo;d start scouring to buy a DRM-free digital copy of their music. During the year, my purchases primarily came from three places:</p>
<ul>
<li>Bandcamp - my preference based on their share for artists, especially on a <a href="https://isitbandcampfriday.com/">Bandcamp Friday</a>.</li>
<li>iTunes - The <a href="https://en.wikipedia.org/wiki/FairPlay#Music">era of iTunes Store music DRM is long over </a> and means that it&rsquo;s honestly a great source for music even if it constantly tries to push you into using Apple Music instead.</li>
<li><a href="https://www.beatport.com/">Beatport</a> - Please DJ&rsquo;s, put your stuff on Bandcamp or iTunes instead.</li>
</ul>
<p>I loaded all the music I purchased into the <a href="/ops/funkwhale-configuration-and-first-impressions/">(private) Funkwhale I set up</a> which I can then easily listen to on any browser when I&rsquo;m at home or work and I have the <a href="https://play.google.com/store/apps/details?id=org.moire.ultrasonic&amp;hl=en_AU">Ultrasonic</a> app on my phone for when I want to listen to tunes on the go.</p>
<p>This is my main option if I&rsquo;m playing music to help me sleep, or if I want to listen to a specific album or artist. I have Funkwhale set to <a href="https://www.last.fm/user/judges119">scrobble to my Last.fm</a> so I can track what I&rsquo;ve been listening too and while that does have some options to discover new tunes I&rsquo;ll admit that Spotify is still the clear winner in that arena. Although I always laughed at some of the <a href="https://assets.denon.com/documentmaster/us/ak-dl1lit.pdf">ridiculous audiophile culture</a> I&rsquo;ll admit I&rsquo;ve noticed substantial difference for some artists and genres between the lossless FLAC from Bandcamp and the streamed Spotify versions so I&rsquo;ll eat a bit of humble pie right now.</p>
<h2 id="why-else-you-should-buy-music">Why Else You Should Buy Music</h2>
<p>Streaming isn&rsquo;t a flash-in-the-pan trend that will disappear - it&rsquo;s not a MiniDisc or VHS where some years down the track you realise you have no way to play <a href="https://en.wikipedia.org/wiki/The_Downward_Spiral#Deluxe_edition_%28Halo_8_DE%29">that album you lost your virginity too</a>. That&rsquo;s the advantage of streaming - if the service ever shuts down you can use one of the others and not lose access things you have paid for in full.</p>
<p>It&rsquo;s more like Uber and Netflix. It&rsquo;s convenient and it&rsquo;s incredibly cheap although the price has gone up a few times. Sure there&rsquo;s been it&rsquo;s <a href="https://www.bbc.com/news/technology-40352868">fair share of scandals</a> and some incredibly bad business decisions and <a href="https://www.forbes.com/sites/antoniopequenoiv/2024/02/02/joe-rogan-inks-new-spotify-deal-worth-up-to-250-million-report-says/">weird catering to nutjobs</a> but we can&rsquo;t deny that it&rsquo;s convenient even if <a href="https://www.vice.com/en/article/jordan-peterson-chinese-dick-sucking-factory/">it platforms absolute freaks</a>.</p>
<p>But Uber kept rising in price because it <a href="https://www.theverge.com/2020/5/7/21251111/uber-q1-earnings-rides-loss-eats-delivery-coronavirus"><em>cost more then it was making</em></a>. It survived cheaply for so long because it was paid for by <a href="https://en.wikipedia.org/wiki/Venture_capital">other people&rsquo;s money</a>. Spotify has lasted so well because it got enough investor money that it could eventually go public (<a href="https://www.businessofapps.com/data/spotify-statistics/">despite losing money</a> for all twelve years until that moment) in order to keep making money it needs to <a href="https://harpers.org/archive/2025/01/the-ghosts-in-the-machine-liz-pelly-spotify-musicians/">fuck over the people who make the actual conten</a>t. For every Taylor Swift or Joe Rogan who earn decent dosh from it, there&rsquo;s millions of artists who see a few bucks a month if they&rsquo;re lucky. And because these streaming services became the de facto way to listen to music it made it harder to buy albums (goodbye CD stores) and technology has made it <a href="https://arstechnica.com/gadgets/2020/10/rip-google-play-music-2011-2020/">increasingly more annoying to load your own music</a> on it which helps keep these streaming services on top.</p>
<p>And much like we&rsquo;ve seen with how the video streaming wars have caused <a href="https://flixed.io/netflix-price-hikes">constant price rises</a> and <a href="https://en.wikipedia.org/wiki/2023_SAG-AFTRA_strike">massive backlash from the makers of films/shows</a> we can expect that music streaming will rise in price and find new and creative ways to screw over Australian indie rock sensation Tame Impala and also good music projects.</p>
<p>To an extent though, it&rsquo;s also about preservation.</p>
<p>Spotify doesn&rsquo;t have all the music. Apple Music has even less then it. Tidal, Deezer, and the like have even less. Some things now only exists as rips uploaded to YouTube and many other artists aren&rsquo;t even there. I&rsquo;ve seen music I loved on Spotify disappear due to record company bullshit and no longer be accessible. But if I&rsquo;ve bought a copy of that album, I&rsquo;ve still got it. I can still listen to what brought me to tears or gave me the energy to dance all night. I can still put it on if I&rsquo;m behind decks or showing a friend something new.</p>
<p>So buy music, not just to keep it alive in case it disappears, but to feed the artists so they can keep making more.</p>
<h2 id="anyway">Anyway,</h2>
<p>Buy some tunes from these folks on Bandcamp who released music last year that I loved:</p>
<ul>
<li><a href="https://sirus-official.bandcamp.com/">Sirus</a></li>
<li><a href="https://slayyyter.bandcamp.com/">Slayyyter</a></li>
<li><a href="https://hieroglitch.bandcamp.com/">heiroglitch</a></li>
<li><a href="https://nikkita1.bandcamp.com/">Nikkita</a></li>
<li><a href="https://venjent.bandcamp.com/">Venjent</a></li>
<li><a href="https://theherd.bandcamp.com/track/soul-of-my-soul-feat-sereen-mo-big-rigs">The Herd</a></li>
<li><a href="https://bbnomoney.bandcamp.com/">bbno$</a></li>
<li><a href="https://spellmynamewithabang.bandcamp.com/music">rj lake</a></li>
</ul>
<p>I&rsquo;d also recommend some of the following evergreen amazing artists:</p>
<ul>
<li><a href="https://lilmariko.bandcamp.com/">Lil Mariko</a></li>
<li><a href="https://vnvnation.bandcamp.com/">VNV Nation</a></li>
<li><a href="https://www.ashnikko.com/">Ashnikko</a></li>
<li><a href="https://ashburyheights.bandcamp.com/">Ashbury Heights</a></li>
<li><a href="https://code64official.bandcamp.com/album/departure">Code64</a></li>
<li><a href="https://freshviolet.bandcamp.com/">Fresh Violet</a></li>
<li><a href="https://www.whoistillie.com/">tiLLie</a></li>
</ul>
]]></content>
  </entry>
  
</feed>