Australian BoM Radar Image Analysis via LLM Vision to Detect Severe Storm Arrival using Docker on Synology

All the information below reads the BoM radar, submits it to LLM Vision using OpenAI, reads the response in to an MQTT sensor and creates an animated GIF for use in Lovelace.

The aim is to get the approximate arrival time of a severe storm at my location, plus any associated data, but only run the automation if severe weather sensors have already been tripped which I created from my Weatherflow Tempest data.

I was looking at this from the point of view of replicating AI detection of storms from here: https://www.youtube.com/watch?v=SjGDH2vCv2I

But I needed all 5 separate BoM radar images to be correctly analysed by LLM Vision.

So I used this as a base:

Plus you will need LLM Vision on your HA setup and link it to OpenAI and get an API key that works which will cost you money!!! $10 min.

And on my Synology NAS I did this…

NOTE: I am not an expert. Do not ask me for advice!!! If you can get this working great.

STEP 1: Create a folder to contain all your Docker files such as /volume1/docker/weather_gif. Within that create two directories: app and images. On home assistant, create a folder to receive the files via smb: /config/www/weather_gif.
To the app directory save the background image from the BoM site as IDR.legend.0.png:
https://www.bom.gov.au/products/radar_transparencies/IDR.legend.0.png

Note that the final directory structure will look like this:

docker/
└── weather_gif/
    β”œβ”€β”€ app/
    β”‚   β”œβ”€β”€ Dockerfile
    β”‚   β”œβ”€β”€ IDR.legeng.0.png
    β”‚   β”œβ”€β”€ requirements.txt
    β”‚   β”œβ”€β”€ weather_gif.py
    β”‚   └── weather_gif_script.py
    └── images/
        β”œβ”€β”€ image_1.png
        β”œβ”€β”€ image_2.png
        β”œβ”€β”€ image_3.png
        β”œβ”€β”€ image_4.png
        β”œβ”€β”€ image_5.png
        └── radar_animated.gif

STEP 2: In the docker app folder create a python script called weather_gif.py to build the image and send it to Home Assistant and put it in that folder. In the code, select your own radar source and set your SMB parameters for your local HA setup.

#!/usr/bin/env python3
import io
import ftplib
import smbclient
import os
from PIL import Image
from datetime import datetime
import pytz

# The ID for our radar image
product_id = 'IDR952' # CHANGE THIS!

# Timezone for timestamp display
local_timezone = 'Australia/Melbourne' # CHANGE THIS!

# Home Assistant SMB share details
smb_server = "192.168.X.X" # CHANGE THIS!
smb_share_name = "config"
smb_username = os.environ.get('SMB_USERNAME', 'XXXXXX')  # CHANGE THIS!
smb_password = os.environ.get('SMB_PASSWORD', 'XXXXXX')  # CHANGE THIS!
smb_remote_path = "/www/weather_gif"

# Timestamp file name
timestamp_filename = "radar_last_update.txt"

# Animated GIF settings
animated_gif_filename = "radar_animated.gif"
gif_duration = 500  # Duration of each frame in milliseconds
gif_loop = 0  # 0 = infinite loop

# Local directory containing images (mounted volume)
local_image_directory = "/images"

# Legend image path (in the Docker build directory)
legend_image_path = "/app/IDR.legend.0.png"

# Create the directory if it doesn't exist
os.makedirs(local_image_directory, exist_ok=True)

# List to store the images and filenames
frames = []
saved_filenames = []

# The layers that we want
layers = ['background', 'catchments', 'topography', 'locations']

try:
    # Load the legend image as the base
    base_image = None
    if os.path.exists(legend_image_path):
        base_image = Image.open(legend_image_path).convert('RGBA')
        print(f"Loaded legend image as base: {legend_image_path} - Size: {base_image.size}")
    else:
        print(f"ERROR: Legend image not found at {legend_image_path}")
        raise FileNotFoundError(f"Legend image required at {legend_image_path}")
    
    # Connect to FTP server once
    ftp = ftplib.FTP('ftp.bom.gov.au')
    ftp.login()
    
    # Build composite layers on top of the legend base
    ftp.cwd('anon/gen/radar_transparencies/')
    
    for layer in layers:
        filename = f"{product_id}.{layer}.png"
        file_obj = io.BytesIO()
        ftp.retrbinary('RETR ' + filename, file_obj.write)
        file_obj.seek(0)  # Reset pointer to start
        
        image = Image.open(file_obj).convert('RGBA')
        # Paste each layer at (0, 0) on top of the base (which includes the legend)
        base_image.paste(image, (0, 0), image)
        print(f"Added layer: {layer}")
    
    print(f"Base image with all layers size: {base_image.size}")
    
    # Get radar images from the same FTP connection
    ftp.cwd('/anon/gen/radar/')  # Use absolute path
    
    # Get all matching radar files
    all_files = [file for file in ftp.nlst()
                 if file.startswith(product_id)
                 and file.endswith('.png')]
    
    # Sort by timestamp in filename (format: IDR022.T.YYYYMMDDHHmm.png)
    # Extract timestamp for sorting
    def get_timestamp(filename):
        try:
            parts = filename.split('.')
            if len(parts) >= 3:
                return parts[2]  # YYYYMMDDHHmm
            return filename
        except:
            return filename
    
    sorted_files = sorted(all_files, key=get_timestamp)
    
    # Get the last 5 (most recent) radar images
    files = sorted_files[-5:]
    
    print(f"Found {len(all_files)} total radar files")
    print(f"Selected most recent 5: {files}")
    
    # Download and composite the radar images
    for file in files:
        file_obj = io.BytesIO()
        try:
            ftp.retrbinary('RETR ' + file, file_obj.write)
            file_obj.seek(0)  # Reset pointer
            image = Image.open(file_obj).convert('RGBA')
            frame = base_image.copy()
            # Paste radar data at (0, 0) - it will overlay on the radar area, not the legend
            frame.paste(image, (0, 0), image)
            frames.append(frame)
            print(f"Processed {file}")
        except ftplib.all_errors as e:
            print(f"Error downloading {file}: {e}")
    
    ftp.quit()
    
    # Save individual PNG images to the local directory
    for i, img in enumerate(frames):
        filename = f"image_{i+1}.png"
        filepath = os.path.join(local_image_directory, filename)
        img.save(filepath)
        saved_filenames.append(filename)
        print(f"Saved {filepath}")
    
    # Save animated GIF to local directory
    if frames:
        gif_filepath = os.path.join(local_image_directory, animated_gif_filename)
        frames[0].save(
            gif_filepath,
            save_all=True,
            append_images=frames[1:],
            duration=gif_duration,
            loop=gif_loop,
            optimize=False
        )
        saved_filenames.append(animated_gif_filename)
        print(f"Saved animated GIF: {gif_filepath}")
    
    # Extract timestamp from the last radar file
    last_radar_file = files[-1] if files else None
    timestamp_content = None
    
    if last_radar_file:
        # BOM radar files are formatted like: IDR022.T.202509302234.png
        try:
            parts = last_radar_file.split('.')
            if len(parts) >= 3:
                # The timestamp is in the third part (index 2)
                datetime_str = parts[2]  # YYYYMMDDHHmm format
                
                # Parse the datetime (BOM times are in UTC)
                dt_utc = datetime.strptime(datetime_str, "%Y%m%d%H%M")
                dt_utc = pytz.utc.localize(dt_utc)
                
                # Convert to local timezone
                local_tz = pytz.timezone(local_timezone)
                dt_local = dt_utc.astimezone(local_tz)
                
                # Format both UTC and local times for the file content
                utc_time = dt_utc.strftime("%Y-%m-%d %H:%M UTC")
                local_time = dt_local.strftime("%Y-%m-%d %H:%M %Z")
                
                timestamp_content = f"UTC Time: {utc_time}; Local Time ({local_timezone}): {local_time}\n"
                
                print(f"Last radar image - UTC: {utc_time}, Local: {local_time}")
        except Exception as e:
            print(f"Error parsing timestamp from {last_radar_file}: {e}")
            timestamp_content = f"Last file: {last_radar_file}\nError parsing timestamp: {e}\n"
    
    # Transfer to SMB share
    if saved_filenames:
        # Configure SMB client
        smbclient.ClientConfig(username=smb_username, password=smb_password)
        
        # Build SMB destination path
        smb_destination_path = f"//{smb_server}/{smb_share_name}{smb_remote_path}"
        
        # Create destination directory
        try:
            smbclient.makedirs(smb_destination_path, exist_ok=True)
        except Exception as e:
            print(f"Warning: Could not create directory: {e}")
        
        # Transfer each saved file (PNGs and GIF)
        for file_name in saved_filenames:
            local_file_path = os.path.join(local_image_directory, file_name)
            smb_file_path = f"{smb_destination_path}/{file_name}"
            
            print(f"Transferring {file_name}...")
            try:
                with open(local_file_path, 'rb') as local_file:
                    with smbclient.open_file(smb_file_path, mode="wb") as smb_file:
                        smb_file.write(local_file.read())
                print(f"Successfully transferred {file_name}")
            except Exception as e:
                print(f"Failed to transfer {file_name}: {e}")
        
        # Write timestamp file to SMB share
        if timestamp_content:
            timestamp_file_path = f"{smb_destination_path}/{timestamp_filename}"
            try:
                with smbclient.open_file(timestamp_file_path, mode="w") as timestamp_file:
                    timestamp_file.write(timestamp_content)
                print(f"Successfully wrote timestamp file: {timestamp_filename}")
            except Exception as e:
                print(f"Failed to write timestamp file: {e}")
    
except ftplib.all_errors as e:
    print(f"FTP Error: {e}")
except smbclient.exceptions.SMBException as e:
    print(f"SMB Error: {e}")
except Exception as e:
    print(f"Unexpected error: {e}")
finally:
    smbclient.reset_connection_cache()```

STEP 3: In the docker folder create a requirements.txt file

Pillow
smbprotocol
pin
pytz

STEP 4: In the docker app folder create a Dockerfile (litterally called Dockerfile with an uppercase D)

FROM python:3.13.7
WORKDIR /app
COPY weather_gif.py .
COPY requirements.txt .
COPY IDR.legend.0.png .

RUN pip install -r requirements.txt

CMD ["python", "weather_gif.py"]

STEP 5: Create compose.yaml file and put it somewhere near your other compose.yaml’s. It does not need to be in the same folder as the previous files. E.g. /volume1/docker/projects/weather_gif_compose

version: '3.8'
services:
  weather_gif:
    container_name: weather_gif
    build: /volume1/docker/weather_gif/app
    volumes:
      - /volume1/docker/weather_gif/images:/images

STEP 6: In the docker app folder create a script to build and run the project. Call it weather_gif_script.py or something more useful

cd /volume1/docker/projects/weather_gif_compose && docker-compose up --build --force-recreate --abort-on-container-exit

STEP 7: Create a user-defined script on Synology to execute that script every 10 minutes (or whatever you consider appropriate).

STEP 8: Use the images in a Home Assistant automation, such as one that talks to LLM Vision.

STEP 9: I created an HA automation to send the images to LLM Vision:

You will need to update the sensor that triggers this, create the input_text helper to store the output, change the location of your radar station and change the local timezone. If you don’t use the 256k radar image, change this too.
This automation is still pretty rudimentry but seems to get the right data in to the helper.

alias: Weather - AI Alert System
description: >-
  This automation provides AI-powered weather alerts based on radar analysis and
  BoM warnings.
triggers:
  - entity_id:
      - binary_sensor.bom_severe_thunderstorm_warning
    trigger: state
    to: "on"
conditions: []
actions:
  - alias: Analyze current doppler radar map
    action: llmvision.image_analyzer
    metadata: {}
    data:
      provider: FROM_OPENAI_ACCOUNT
      model: gpt-4o
      include_filename: false
      max_tokens: 264
      temperature: 0.5
      message: >
  Analyze 5 sequential radar images from Rainbow Wimmera (256km radius, center
  -35.9955311297678, 142.01537171440987). Report how rain may impact home zone
  ({{ state_attr('zone.home','latitude') }},{{
  state_attr('zone.home','longitude') }}).

    Analyse whether a severe, damaging storm may strike the home zone within the
    next hour.
  
    Output must be a single valid JSON object, nothing else, no markdown, no code
    fences, no commentary. Use exactly these keys: - current_rain_trend -
    movement_dir_speed - arrival_time_home - intensity - home_zone_town_name -
    last_image_time
  
    Parse each radar image timestamp strictly as a UTC instant. As a reference,
    image_5:png was recorded at {{
    states('sensor.muckleford_south_bom_radar_last_update_raw') }}. Report  all
    converted times in the format "HH:MM AM/PM".
  
    Example of required format (but with real data filled in): {
      "current_rain_trend": "Rain is increasing and moving towards the home zone.",
      "movement_dir_speed": "Eastward at a moderate speed.",
      "arrival_time_home": "09:15 AM",
      "intensity": "Heavy",
      "damaging_storm_alert": "True",
      "last_image_time": "08:54 AM",
      "home_zone_town_name": "Newstead"
    }
    image_file: |-
        /config/www/weather_gif/image_1.png
        /config/www/weather_gif/image_2.png
        /config/www/weather_gif/image_3.png
        /config/www/weather_gif/image_4.png
        /config/www/weather_gif/image_5.png
    response_variable: radar_visual_analysis
    enabled: true
  - action: mqtt.publish
    metadata: {}
    data:
      qos: 0
      retain: true
      topic: weather/alert
      payload: "{{ radar_visual_analysis.response_text }}"
mode: single
initial_state: "on"

STEP 10: Create an MQTT sensor to read the Json-formatted response:

mqtt:
sensor:
  - name: "BoM Radar Forecast"
    unique_id: bom_radar_forecast
    state_topic: "weather/alert"
    value_template: "{{ value_json.arrival_time_home }}"
    json_attributes_topic: "weather/alert"

Simples!

3 Likes

The installation of the Docker component has now been simplified to a Home Assistant addon.

This makes installation a lot simpler and lets you focus on the AI prompt, which is the hard part.

Did this cost you $100million to develop? Grins!