Avoid the heatwave!

october 10, 2026

The past week, San Diego experienced an abysmal heatwave. It not only terrorized the day, but it also haunted my good night’s sleep - I would wake up in the middle of the night with my neck wet from sweat.

This conundrum motivated me to make a path planning algorithm that avoids the sunlight and “seeks” the shade (a penalty if you are in the sun).

In explaining this, I'll start with the algorithm, and then move to the more nitty-gritty maths (it's still simple! just will seem a bit overwhelming if I put it in the beginning). Also, if you want to try the algorithm, scroll to the bottom!

1.Modelling the shadow

Let’s start by thinking about shadows, so we get a gist of what we’re working with. Frankly, this section won’t be helpful until we have figured out the algorithm.

Here, we’ll use the parallel light rays model. Assume the sun emits parallel rays . If we have a plane in Hessian form, , then the shadow of a point is simply where the ray through along lands on the plane. That ray is , so its intersection with is

Parallel sun rays along l hit the top corner x of a building of height h. The ray continues as lambda times l down to the ground plane P, n dot x = d, landing at S = x + lambda l, a distance h over tan e from the building, where e is the sun's elevation.
The shadow of the corner lands at .

2.The modified A* algorithm

Since we still want to find the shortest path, A* is our best friend. To recap, here’s how the A* algorithm works:

import heapq

def a_star(start, goal, neighbors, d, h):
    open_set = [(h(start), start)]  # min-heap of (f, node)
    came_from = {}
    g_score = {start: 0}

    while open_set:
        _, current = heapq.heappop(open_set)  # lowest f first
        if current == goal:
            path = [current]
            while current in came_from:
                current = came_from[current]
                path.append(current)
            return path[::-1]

        for neighbor in neighbors(current):
            tentative_g = g_score[current] + d(current, neighbor)
            if tentative_g < g_score.get(neighbor, float("inf")):
                came_from[neighbor] = current
                g_score[neighbor] = tentative_g
                heapq.heappush(open_set, (tentative_g + h(neighbor), neighbor))

    return None  # no path
A*, the textbook version.

In simple words, we minimize at every step:

where is the cost of the path we have gone through, and is the heuristic we use to estimate the cost still left to reach the goal - so when the cost is just distance, people typically use the Euclidean or the Manhattan distance.

A grid from S to G. A solid path labelled g(v), walked, runs from S to a cell v; a dashed line labelled h(v), guess, runs from v to G.
is what we’ve walked, is our heuristic - in this illustration, it's the Euclidean distance.

However, picking the minimal is just a way of deciding where to explore next. Once we pick that cell, we look at each of its neighbors and compute a new for it: the cost of reaching that neighbor through the current cell. If this new is lower than the the neighbor already had, we’ve found a cheaper route to it, so we update its and remember the current cell as its parent. Otherwise we leave it alone. When we finally reach the goal, we follow these parents back to recover the path.

We will add a penalty to (the path cost) so that heat gets considered. Assume is the extra cost, so:

where is our path cost for this modified algorithm and is the usual path cost, which in our case means the distance we have walked so far. For the heuristic, we’ll use the typical Euclidean distance, nothing fancy:

Two grids from S to G. With p = 0 the path goes straight through the sunny top row at cost 3. With p = 4 the sunny cells cost 5 each, so the path detours through the shaded bottom row at cost 5.
Example run on different values.

3.The extra cost function

Intuitively, if the sun is directly on top of us, it’s far brighter and hotter. If its elevation is lower, it’s less intense. This is a simple explanation for Lambert’s cosine law - the equation we’ll use for the penalty:

where is the sun’s irradiance and is the irradiance “felt” by the surface. Here is measured from the surface normal, not the ground. For flat ground with the sun at elevation , , so

A beam of sunlight arrives at elevation e above the ground, at angle theta from the surface normal n, and spreads over a wider patch of ground. The vector s = -l points back toward the sun. E = E0 cos theta = E0 sin e = E0 n dot s.

where points toward the sun. Combining this with the intensity of light that we'll further explain in section 5:

Shaded cells have regardless of the sun; sunny cells get hotter as the sun climbs. Finally, the extra cost per metre of a cell is

where is the heat penalty, a single number (set by a slider) that is the same for every cell. Summed along a path, the total cost becomes

i.e. the distance walked plus times the “metres of sun”. So acts as an exchange rate: A* will walk up to extra metres to avoid one metre of full sun.

Here’s the search itself, where each cell’s cost is :

import heapq
import math

MOVES = [(dr, dc, math.hypot(dr, dc)) for dr in (-1, 0, 1) for dc in (-1, 0, 1) if dr or dc]


def astar(cost, blocked, start, goal):
    rows, cols = cost.shape
    cost, blocked = cost.tolist(), blocked.tolist()
    g = [[math.inf] * cols for _ in range(rows)]
    parent = {}
    g[start[0]][start[1]] = 0.0
    frontier = [(math.dist(start, goal), 0.0, start)]
    while frontier:
        _, g_cur, cur = heapq.heappop(frontier)
        if cur == goal:
            path = [cur]
            while cur in parent:
                cur = parent[cur]
                path.append(cur)
            return path[::-1]
        r, c = cur
        if g_cur > g[r][c]:
            continue
        for dr, dc, step in MOVES:
            nr, nc = r + dr, c + dc
            if not (0 <= nr < rows and 0 <= nc < cols) or blocked[nr][nc]:
                continue
            if dr and dc and (blocked[nr][c] or blocked[r][nc]):
                continue
            g_new = g_cur + step * 0.5 * (cost[r][c] + cost[nr][nc])
            if g_new < g[nr][nc]:
                g[nr][nc] = g_new
                parent[(nr, nc)] = cur
                heapq.heappush(frontier, (g_new + math.dist((nr, nc), goal), g_new, (nr, nc)))
    return None
astar.py: the path search.

Awesome, so that’s our avoid-the-heatwave A* algorithm. Nevertheless, we need to build the world to see how the algorithm works and define the equation for heat. For the world, ideally we want to implement it with SDFs in mind for versatility. I’ll explain SDFs and shadow tracing, but will skip baking and primitives.

4.The world as signed distance functions

An SDF gives, for any point , the distance to the nearest surface, with a sign:

The key property: a ball of radius around contains no solid. Additionally, shapes are combined with simple operations, e.g. union is and subtraction is , so a whole city is just one function.

A rectangle where f = 0 on its edge and f = -0.5 inside, surrounded by rounded contours at f = 0.5, 1.0 and 1.5. From an outside point p, an arrow of length f(p) reaches the nearest corner, and a dashed circle of that radius touches the rectangle without entering it.
Level sets of a box’s SDF. The ball around just touches the box.

5.Shadow tracing

Okay, so back in section 1, we asked: where does the shadow of a point land?

That works for solid objects, but it gets kinda messy when we work with hollow objects. So we flip the question: from a ground point , does a ray toward the sun hit anything?

Basically, if the ray hits something on the way to the sun, we’re in the shade. Same shadow, just asked backwards. So how do we check if the ray hits something? Sphere tracing! Imagine walking from toward the sun along

where is how far we’ve walked (in metres) and is where we are. At every step, we ask the SDF “how far is the closest wall?” and just walk that far:

where is the step number. This is safe because nothing is inside that ball (section 4), so we can never walk through a wall. Close to a wall the steps get tiny, and once is basically zero () we’ve hit it, so is in shade ( is the "close enough to touching" threshold). If we walk out of the world without hitting anything, is in full sun.

A ray from ground point X toward the sun s, marching through a chain of circles, each as big as the distance to the nearest building. The circles shrink near a tall building, where the ray stops: hit, so shade.
Sphere tracing toward the sun. A hit means shade.

Now, in real life, shadow edges are kind of blurry, meaning that we need to consider rays that “almost” hit something. At each step, is how close we pass to a wall, and is how far we are from . Seen from , that near-miss looks like an angle of about . The closest near-miss decides how much light gets through:

where controls how sharp the edge is - a variable in our code. Clamp just keeps the answer between 0 (full shade) and 1 (full sun). (Here is just a distance, not the A* heuristic.) A huge gives back the sharp shadow from section 1. The real sun would be around , but I use so the planner stays a bit away from shadow edges.

The same ray from X just grazes the corner of the tall building. The closest step, h5 = 0.33 at t5 = 6.97, is highlighted, and the near-miss angle seen from X is marked as about h5 over t5.
A near miss: the ray grazes the roof at an angle of about .

And this light is exactly what we plug into in section 3. So the full pipeline is: shadow tracing → heat → cost → A*.

And that’s it! Here’s the full pipeline of avoid the heatwave:

Flowchart: world.py (scene SDF f(p)) to bake() (grid copy) to soft_shadow() (light in [0, 1] per cell) to heat = light times sin e to cost = 1 + p times heat to astar() (coolest path). The light map and the path both feed matplotlib, which draws the picture and the path.

And here it is running. This is the actual Python - world.py, sdf.py, render.py and astar.py - running in your browser with Pyodide. Tap to place the start or goal (right-click places the goal), drag the sun around, or let it move:

python loads when you get here…

 

tap sets /

schrödinger's finite well