Compare commits

..

5 commits

Author SHA1 Message Date
6ca6a2dbd1 Post another map image if the area/geometry has been modified
All checks were successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/vulnerability-scan Pipeline was successful
ci/woodpecker/push/docker-buildx Pipeline was successful
2025-07-09 19:09:15 -07:00
000acf19e3 Split map image generation into a new function 2025-07-09 19:02:17 -07:00
3c40e695a7 Fix Pillow attribution rectangle fill color 2025-07-09 19:01:17 -07:00
635097da91 Change POST_DATETIME_FORMAT to const 2025-07-09 18:56:51 -07:00
2bfd4880f4 Update config-sample 2025-07-09 17:50:21 -07:00
2 changed files with 127 additions and 110 deletions

View file

@ -1,10 +1,12 @@
osm:
url_template: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: © OpenStreetMap
health_check_url:
nominatim:
api_base_url:
api_base_url: https://nominatim.openstreetmap.org
health_check_url: https://nominatim.openstreetmap.org/status
mastodon:
client_id:
client_secret:
access_token:
api_base_url:
client_id:
client_secret:
access_token:
api_base_url:

225
scl.py
View file

@ -1,7 +1,7 @@
import io
import re
from datetime import datetime
from typing import Optional
from typing import Optional, Tuple
import mastodon
import requests
@ -9,6 +9,7 @@ import shapely
import staticmap
import yaml
from mastodon import Mastodon
from mastodon.return_types import MediaAttachment
from PIL import Image, ImageDraw, ImageFont
from shapely import Geometry
from sqlalchemy import create_engine, select
@ -18,7 +19,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from geospatial import DBGeometry, convert_outage_geometry
REQUESTS_HEADERS = {"User-Agent": "seattlecitylight-mastodon-bot"}
post_datetime_format = "%b %e %l:%M %p"
POST_DATETIME_FORMAT = "%b %e %l:%M %p"
scl_events_url = "https://utilisocial.io/datacapable/v2/p/scl/map/events"
scl_events_response = requests.get(scl_events_url)
@ -83,7 +84,6 @@ class AttribStaticMap(staticmap.StaticMap, object):
textSize = fnt.getbbox(self.attribution)
textPosition = (image.size[0] - textSize[2], image.size[1] - textSize[3])
offset = 2
options = {"fill": (255, 255, 255, 180)}
d.rectangle(
[
(textPosition[0] - (2 * offset), textPosition[1] - (2 * offset)),
@ -92,7 +92,7 @@ class AttribStaticMap(staticmap.StaticMap, object):
textSize[3] + textPosition[1] + (2 * offset),
),
],
**options,
fill=(255, 255, 255, 180),
)
# draw text, full opacity
@ -152,6 +152,108 @@ def get_hashtag_string(event) -> str:
return hashtag_string
def generate_post_map_image(
event, event_class, outage_geometries: shapely.MultiPolygon
) -> Tuple[MediaAttachment, str]:
# Fallback location from the SCL API in case one couldn't be reverse geocoded
area_text = event["city"]
map = AttribStaticMap(
1024,
1024,
url_template=osm_url_template,
tile_size=512,
)
assert event["polygons"]["type"] == "polygon"
for ring in event["polygons"]["rings"]:
polygon = staticmap.Polygon(
ring,
# Appending 7F to the fill_color makes it 50% transparent
fill_color="{}7F".format(event_class["outage_color"]),
outline_color=event_class["outage_color"],
simplify=True,
)
map.add_polygon(polygon)
try:
outage_center: shapely.Point = outage_geometries.centroid
assert outage_center.geom_type == "Point"
# Check to make sure the calculated lat and lon are sane enough
# NW Corner
assert outage_center.y < 48 and outage_center.x > -122.6
# SE Corner
assert outage_center.y > 47.2 and outage_center.x < -122
# Zoom level 17 ensures that we won't get any building/POI names, just street names
geocode_url = "{nominatim_url}/reverse?lat={lat}&lon={lon}&format=geocodejson&zoom=17".format(
nominatim_url=nominatim_url,
lat=outage_center.y,
lon=outage_center.x,
)
geocode_response = requests.get(geocode_url, headers=REQUESTS_HEADERS)
try:
geocode = geocode_response.json()
except requests.JSONDecodeError:
print("JSON could not be loaded from nominatim API")
raise
city = geocode["features"][0]["properties"]["geocoding"]["city"]
street = geocode["features"][0]["properties"]["geocoding"]["name"]
event["geoloc_city"] = city
if city != "Seattle":
city_not_seattle_text = " of {}".format(city)
else:
city_not_seattle_text = ""
if (
"locality" in geocode["features"][0]["properties"]["geocoding"]
and event_class["size"] != "Large"
):
locality = geocode["features"][0]["properties"]["geocoding"]["locality"]
if locality == "Uptown":
locality = "Lower Queen Anne"
alt_text = "A map showing the location of the outage, centered around {} in the {} area{}.".format(
street,
locality,
city_not_seattle_text,
)
area_text = "the {} area{}".format(locality, city_not_seattle_text)
event["neighborhood"] = locality
elif "district" in geocode["features"][0]["properties"]["geocoding"]:
district = geocode["features"][0]["properties"]["geocoding"]["district"]
alt_text = "A map showing the location of the outage, centered around {} in the {} area{}.".format(
street,
district,
city_not_seattle_text,
)
area_text = "the {} area{}".format(
district,
city_not_seattle_text,
)
event["neighborhood"] = district
else:
alt_text = "A map showing the location of the outage, centered around {} in {}.".format(
street,
city,
)
area_text = city
except Exception:
alt_text = "A map showing the location of the outage."
map_image = map.render()
with io.BytesIO() as map_image_file:
map_image.save(map_image_file, format="WebP", method=6)
map_media_post = mastodon_client.media_post(
map_image_file,
mime_type="image/webp",
description=alt_text,
)
return (map_media_post, area_text)
def do_initial_post(
event,
event_class,
@ -160,118 +262,25 @@ def do_initial_post(
outage_geometries: shapely.MultiPolygon,
) -> dict[str, str | None]:
post_id = None
map_media_post_id = None
# Fallback location from the SCL API in case one couldn't be reverse geocoded
area_text = event["city"]
map_media_post = None
area_text = str()
try:
map = AttribStaticMap(
1024,
1024,
url_template=osm_url_template,
tile_size=512,
map_media_post, area_text = generate_post_map_image(
event, event_class, outage_geometries
)
assert event["polygons"]["type"] == "polygon"
for ring in event["polygons"]["rings"]:
polygon = staticmap.Polygon(
ring,
# Appending 7F to the fill_color makes it 50% transparent
fill_color="{}7F".format(event_class["outage_color"]),
outline_color=event_class["outage_color"],
simplify=True,
)
map.add_polygon(polygon)
try:
outage_center: shapely.Point = outage_geometries.centroid
assert outage_center.geom_type == "Point"
# Check to make sure the calculated lat and lon are sane enough
# NW Corner
assert outage_center.y < 48 and outage_center.x > -122.6
# SE Corner
assert outage_center.y > 47.2 and outage_center.x < -122
# Zoom level 17 ensures that we won't get any building/POI names, just street names
geocode_url = "{nominatim_url}/reverse?lat={lat}&lon={lon}&format=geocodejson&zoom=17".format(
nominatim_url=nominatim_url,
lat=outage_center.y,
lon=outage_center.x,
)
geocode_response = requests.get(geocode_url, headers=REQUESTS_HEADERS)
try:
geocode = geocode_response.json()
except requests.JSONDecodeError:
print("JSON could not be loaded from nominatim API")
raise
city = geocode["features"][0]["properties"]["geocoding"]["city"]
street = geocode["features"][0]["properties"]["geocoding"]["name"]
event["geoloc_city"] = city
if city != "Seattle":
city_not_seattle_text = " of {}".format(city)
else:
city_not_seattle_text = ""
if (
"locality" in geocode["features"][0]["properties"]["geocoding"]
and event_class["size"] != "Large"
):
locality = geocode["features"][0]["properties"]["geocoding"]["locality"]
if locality == "Uptown":
locality = "Lower Queen Anne"
alt_text = "A map showing the location of the outage, centered around {} in the {} area{}.".format(
street,
locality,
city_not_seattle_text,
)
area_text = "the {} area{}".format(locality, city_not_seattle_text)
event["neighborhood"] = locality
elif "district" in geocode["features"][0]["properties"]["geocoding"]:
district = geocode["features"][0]["properties"]["geocoding"]["district"]
alt_text = "A map showing the location of the outage, centered around {} in the {} area{}.".format(
street,
district,
city_not_seattle_text,
)
area_text = "the {} area{}".format(
district,
city_not_seattle_text,
)
event["neighborhood"] = district
else:
alt_text = "A map showing the location of the outage, centered around {} in {}.".format(
street,
city,
)
area_text = city
except Exception:
alt_text = "A map showing the location of the outage."
map_image = map.render()
with io.BytesIO() as map_image_file:
map_image.save(map_image_file, format="WebP", method=6)
map_media_post = mastodon_client.media_post(
map_image_file.getvalue(),
mime_type="image/webp",
description=alt_text,
)
map_media_post_id = map_media_post["id"]
except Exception as e:
print(e)
print(
"Ran into an issue with generating/uploading the map. Will post without it."
)
map_media_post = None
hashtag_string = get_hashtag_string(event)
est_restoration_post_text = str()
if estimated_restoration_time > datetime.now():
est_restoration_post_text = "\nEst. Restoration: {}\n".format(
estimated_restoration_time.strftime(post_datetime_format)
estimated_restoration_time.strftime(POST_DATETIME_FORMAT)
)
post_text = """Seattle City Light is reporting a {} outage in {}.
@ -282,7 +291,7 @@ Cause: {}
{}""".format(
event_class["size"].lower(),
area_text,
start_time.strftime(post_datetime_format),
start_time.strftime(POST_DATETIME_FORMAT),
est_restoration_post_text,
event["cause"],
hashtag_string,
@ -364,6 +373,7 @@ with Session(engine) as session:
existing_record = lookup_result.one()
updated_properties = []
updated_entries = []
map_media_post = None
est_restoration_diff_mins = (
abs(
@ -382,7 +392,7 @@ with Session(engine) as session:
updated_properties.append("estimated restoration")
updated_entries.append(
"Est. Restoration: {}".format(
estimated_restoration_time.strftime(post_datetime_format)
estimated_restoration_time.strftime(POST_DATETIME_FORMAT)
)
)
if event["cause"] != existing_record.cause:
@ -406,8 +416,12 @@ with Session(engine) as session:
max_event_class = classify_event_size(existing_record.max_num_people)
if existing_record.outage_geometries != outage_geometries:
print("Geometries modified")
updated_properties.append("area")
existing_record.outage_geometries = outage_geometries
existing_record.geometries_modified = True
map_media_post, _ = generate_post_map_image(
event, event_class, outage_geometries
)
if updated_properties:
updated_properties.sort()
@ -432,6 +446,7 @@ with Session(engine) as session:
post_result = mastodon_client.status_post(
status="\n".join(updated_entries),
in_reply_to_id=existing_record.most_recent_post_id,
media_ids=map_media_post,
visibility="public",
language="en",
)