From 2bfd4880f4eeee36c7f51019c78d051113f83ed9 Mon Sep 17 00:00:00 2001 From: Liam Steckler Date: Wed, 9 Jul 2025 17:50:21 -0700 Subject: [PATCH 1/5] Update config-sample --- config-sample.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/config-sample.yml b/config-sample.yml index ef502b7..e1c3eeb 100644 --- a/config-sample.yml +++ b/config-sample.yml @@ -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: From 635097da91872274abb218035443d3fb1c8c8513 Mon Sep 17 00:00:00 2001 From: Liam Steckler Date: Wed, 9 Jul 2025 18:56:51 -0700 Subject: [PATCH 2/5] Change POST_DATETIME_FORMAT to const --- scl.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scl.py b/scl.py index e964b2e..0b2fa5f 100644 --- a/scl.py +++ b/scl.py @@ -18,7 +18,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) @@ -271,7 +271,7 @@ def do_initial_post( 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 +282,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, @@ -382,7 +382,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: From 3c40e695a7612af124203b335587c957c7af7031 Mon Sep 17 00:00:00 2001 From: Liam Steckler Date: Wed, 9 Jul 2025 19:01:17 -0700 Subject: [PATCH 3/5] Fix Pillow attribution rectangle fill color --- scl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scl.py b/scl.py index 0b2fa5f..a9c8520 100644 --- a/scl.py +++ b/scl.py @@ -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 From 000acf19e380df1a4ddda9365a80ed811e838a39 Mon Sep 17 00:00:00 2001 From: Liam Steckler Date: Wed, 9 Jul 2025 19:02:17 -0700 Subject: [PATCH 4/5] Split map image generation into a new function --- scl.py | 209 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 109 insertions(+), 100 deletions(-) diff --git a/scl.py b/scl.py index a9c8520..7701d42 100644 --- a/scl.py +++ b/scl.py @@ -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 @@ -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)), @@ -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,112 +262,19 @@ 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() From 6ca6a2dbd1075d5a73fd60acbd7286256d5ae640 Mon Sep 17 00:00:00 2001 From: Liam Steckler Date: Wed, 9 Jul 2025 19:09:15 -0700 Subject: [PATCH 5/5] Post another map image if the area/geometry has been modified --- scl.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scl.py b/scl.py index 7701d42..5101962 100644 --- a/scl.py +++ b/scl.py @@ -373,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( @@ -415,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() @@ -441,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", )