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,8 +1,10 @@
osm: osm:
url_template: https://tile.openstreetmap.org/{z}/{x}/{y}.png url_template: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: © OpenStreetMap attribution: © OpenStreetMap
health_check_url:
nominatim: nominatim:
api_base_url: api_base_url: https://nominatim.openstreetmap.org
health_check_url: https://nominatim.openstreetmap.org/status
mastodon: mastodon:
client_id: client_id:
client_secret: client_secret:

225
scl.py
View file

@ -1,7 +1,7 @@
import io import io
import re import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, Tuple
import mastodon import mastodon
import requests import requests
@ -9,6 +9,7 @@ import shapely
import staticmap import staticmap
import yaml import yaml
from mastodon import Mastodon from mastodon import Mastodon
from mastodon.return_types import MediaAttachment
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from shapely import Geometry from shapely import Geometry
from sqlalchemy import create_engine, select 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 from geospatial import DBGeometry, convert_outage_geometry
REQUESTS_HEADERS = {"User-Agent": "seattlecitylight-mastodon-bot"} 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_url = "https://utilisocial.io/datacapable/v2/p/scl/map/events"
scl_events_response = requests.get(scl_events_url) scl_events_response = requests.get(scl_events_url)
@ -83,7 +84,6 @@ class AttribStaticMap(staticmap.StaticMap, object):
textSize = fnt.getbbox(self.attribution) textSize = fnt.getbbox(self.attribution)
textPosition = (image.size[0] - textSize[2], image.size[1] - textSize[3]) textPosition = (image.size[0] - textSize[2], image.size[1] - textSize[3])
offset = 2 offset = 2
options = {"fill": (255, 255, 255, 180)}
d.rectangle( d.rectangle(
[ [
(textPosition[0] - (2 * offset), textPosition[1] - (2 * offset)), (textPosition[0] - (2 * offset), textPosition[1] - (2 * offset)),
@ -92,7 +92,7 @@ class AttribStaticMap(staticmap.StaticMap, object):
textSize[3] + textPosition[1] + (2 * offset), textSize[3] + textPosition[1] + (2 * offset),
), ),
], ],
**options, fill=(255, 255, 255, 180),
) )
# draw text, full opacity # draw text, full opacity
@ -152,6 +152,108 @@ def get_hashtag_string(event) -> str:
return hashtag_string 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( def do_initial_post(
event, event,
event_class, event_class,
@ -160,118 +262,25 @@ def do_initial_post(
outage_geometries: shapely.MultiPolygon, outage_geometries: shapely.MultiPolygon,
) -> dict[str, str | None]: ) -> dict[str, str | None]:
post_id = None post_id = None
map_media_post_id = None map_media_post = None
# Fallback location from the SCL API in case one couldn't be reverse geocoded area_text = str()
area_text = event["city"]
try: try:
map = AttribStaticMap( map_media_post, area_text = generate_post_map_image(
1024, event, event_class, outage_geometries
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.getvalue(),
mime_type="image/webp",
description=alt_text,
)
map_media_post_id = map_media_post["id"]
except Exception as e: except Exception as e:
print(e) print(e)
print( print(
"Ran into an issue with generating/uploading the map. Will post without it." "Ran into an issue with generating/uploading the map. Will post without it."
) )
map_media_post = None
hashtag_string = get_hashtag_string(event) hashtag_string = get_hashtag_string(event)
est_restoration_post_text = str() est_restoration_post_text = str()
if estimated_restoration_time > datetime.now(): if estimated_restoration_time > datetime.now():
est_restoration_post_text = "\nEst. Restoration: {}\n".format( 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 {}. post_text = """Seattle City Light is reporting a {} outage in {}.
@ -282,7 +291,7 @@ Cause: {}
{}""".format( {}""".format(
event_class["size"].lower(), event_class["size"].lower(),
area_text, area_text,
start_time.strftime(post_datetime_format), start_time.strftime(POST_DATETIME_FORMAT),
est_restoration_post_text, est_restoration_post_text,
event["cause"], event["cause"],
hashtag_string, hashtag_string,
@ -364,6 +373,7 @@ with Session(engine) as session:
existing_record = lookup_result.one() existing_record = lookup_result.one()
updated_properties = [] updated_properties = []
updated_entries = [] updated_entries = []
map_media_post = None
est_restoration_diff_mins = ( est_restoration_diff_mins = (
abs( abs(
@ -382,7 +392,7 @@ with Session(engine) as session:
updated_properties.append("estimated restoration") updated_properties.append("estimated restoration")
updated_entries.append( updated_entries.append(
"Est. Restoration: {}".format( "Est. Restoration: {}".format(
estimated_restoration_time.strftime(post_datetime_format) estimated_restoration_time.strftime(POST_DATETIME_FORMAT)
) )
) )
if event["cause"] != existing_record.cause: 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) max_event_class = classify_event_size(existing_record.max_num_people)
if existing_record.outage_geometries != outage_geometries: if existing_record.outage_geometries != outage_geometries:
print("Geometries modified") print("Geometries modified")
updated_properties.append("area")
existing_record.outage_geometries = outage_geometries existing_record.outage_geometries = outage_geometries
existing_record.geometries_modified = True existing_record.geometries_modified = True
map_media_post, _ = generate_post_map_image(
event, event_class, outage_geometries
)
if updated_properties: if updated_properties:
updated_properties.sort() updated_properties.sort()
@ -432,6 +446,7 @@ with Session(engine) as session:
post_result = mastodon_client.status_post( post_result = mastodon_client.status_post(
status="\n".join(updated_entries), status="\n".join(updated_entries),
in_reply_to_id=existing_record.most_recent_post_id, in_reply_to_id=existing_record.most_recent_post_id,
media_ids=map_media_post,
visibility="public", visibility="public",
language="en", language="en",
) )