Busabase
Busabase is an open-source (MIT) database and workspace for AI agents — data, docs, skills, and apps in one place. Run it locally, self-host it inside your network, or use Busabase Cloud, and turn age
Port and Coastal Operations Twin for Halifax harbour: AIS, tide, storm surge feeds and a CesiumJS app
DataPython · JavaScriptGitHub @mkieserOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗A digital twin of Halifax Harbour, Nova Scotia, built with Claude Code. It brings together live vessel positions (AIS), the CHS tide level, the MSC coastal flooding risk index, topo-bathymetric terrain and 3D buildings in a CesiumJS app with three panels:
ais_consumer.py has stopped).Almost all of the code was written by Claude and Claude Code from the prompts below, with the decisions made along the way recorded under Decisions.
scripts/
load_rasters.py load and merge the DTM and NONNA-10 bathymetry
export_terrain.py merged raster -> Cloud Optimized GeoTIFF + Cesium terrain tiles
fetch_buildings.py NRCan building footprints for the extent -> building_proj.gpkg
extrude_buildings.py building footprints -> 3D solids
export_buildings_3dtiles.py 3D solids -> Cesium 3D Tiles
ais_consumer.py live AIS from aisstream.io -> GeoParquet, plus berth analysis
ais_live.py websocket feed of the latest vessel positions
storage.py live data in local folders or on S3 (AIS_DIR, TIDE_DIR, ...)
tide_consumer.py CHS IWLS tide level -> Parquet every 5 minutes
tide_live.py websocket feed of the latest tide level and trend
storm_surge_consumer.py MSC coastal flooding risk index -> GeoParquet every 30 minutes
storm_surge_live.py websocket feed of the current storm surge risk
twin_app.py serves the CesiumJS app, terrain, tiles and the three feeds
twin_app/ the app: index.html, app.js, style.css (vercel.json from deploy.sh)
test_*.py unit tests (python -m unittest)
deploy/
Dockerfile one image for the consumers and the twin app (ECS, Lambda, EC2)
template.yaml CloudFormation: Fargate, Lambdas, the EC2 twin app, CloudFront
deploy.sh build, push to ECR and deploy the stack
All data lives in the repository's data/ folder, DATA_DIR in
scripts/load_rasters.py. Generated outputs are written there too. The
inputs it expects are:
| File | What it is |
|---|---|
extent.gpkg | the project extent, a 10 km square polygon in EPSG:2961 |
dtm_1m_utm20_w_4_94.tif | 1 m lidar DTM (NRCan HRDEM), EPSG:2961, CGVD2013 heights. Downloaded from DTM_URL on first run if missing; if the server refuses, download it in a browser and save it here |
NONNA10_4460N06360W.tiff, NONNA10_4460N06370W.tiff, NONNA10_4470N06370W.tiff | CHS NONNA-10 bathymetry tiles, WGS 84, chart datum heights. They come from the CHS NONNA download cart |
auto_building_opti_2.parquet | NRCan automatically extracted buildings for all of Canada (2.4 GB GeoParquet). Download it from BUILDINGS_URL in scripts/fetch_buildings.py with a browser: the server refuses scripted downloads. Not committed |
building_proj.gpkg | the extent's building footprints with elevmin and heightmax, in EPSG:2961. Written by fetch_buildings.py from auto_building_opti_2.parquet |
berth.gpkg | named berth polygons in EPSG:2961, for the berth occupancy analysis. Drawn for this project, not an official dataset |
Everything runs on Python 3.14. Install the packages with:
pip install -r requirements.txt
The AIS consumer needs a free API key from aisstream.io in the
AISSTREAM_API_KEY environment variable. Getting the key needs a GitHub login, so you'll need a
GitHub account. The CHS and MSC APIs need no key.
Run everything from scripts/.
1. Build the terrain and buildings (optional, once, or when the source data changes). The tiles are too large for the repo, so a public copy is on S3 (terrain, buildings), and the app loads it for whichever folder hasn't been built locally:
python export_terrain.py # merge DTM + NONNA-10, write the COG and DATA_DIR/terrain/
python fetch_buildings.py # write DATA_DIR/building_proj.gpkg
python extrude_buildings.py # write DATA_DIR/building_3d.gpkg
python export_buildings_3dtiles.py # write DATA_DIR/buildings_3dtiles/
python load_rasters.py on its own prints a summary of both rasters and writes the merged raster.
2. Start the three consumers (each runs until stopped with Ctrl+C):
AISSTREAM_API_KEY=... python ais_consumer.py
python tide_consumer.py
python storm_surge_consumer.py
3. Run the berth analysis once a day (for example from cron), for the Replay panel:
python ais_consumer.py --compact # yesterday (UTC)
python ais_consumer.py --compact 2026-10-22 # a given day
4. Start the app and open http://localhost:8000:
python twin_app.py
twin_app.py serves the app over HTTP on port 8000 and runs the three websocket feeds itself,
all on port 8765 at /vessels, /tide and /storm_surge (--port and --feed-port change
the two ports). Each feed can also run on its own, for example python tide_live.py.
--no-s3 loads terrain and buildings only from DATA_DIR.
Reading the live data from S3. By default the consumers write to DATA_DIR/ais,
DATA_DIR/tide and DATA_DIR/coastal_flooding. Set AIS_DIR, TIDE_DIR and FLOODING_DIR
to S3 prefixes to have the consumers, the feeds and the app use those instead, with your usual
AWS credentials:
AWS_PROFILE=personal AIS_DIR=s3://kieser-public/ais TIDE_DIR=s3://kieser-public/tide \
FLOODING_DIR=s3://kieser-public/coastal_flooding python twin_app.py
deploy/ runs the live data on AWS, writing to s3://kieser-public:
| What | Where | Data |
|---|---|---|
ais_consumer.py | ECS Fargate, one task, always on | ais/ |
ais_consumer.compact_handler | Lambda, daily at 00:30 UTC | ais/ |
tide_consumer.poll_handler | Lambda, every 5 minutes | tide/ |
storm_surge_consumer.poll_handler | Lambda, every 30 minutes | coastal_flooding/ |
twin_app.py (API and feeds) | EC2 t3.small behind CloudFront | reads all three |
The page (twin_app/) | Vercel, https://twin-port-app.vercel.app/ | none |
All of them run the same image. The AIS consumer isn't on Lambda because the aisstream.io websocket stays open indefinitely and Lambda stops after 15 minutes. The tide and storm surge consumers only poll every 5 or 30 minutes, so each Lambda run does one poll and exits.
Start Docker, then run this with credentials for the account that owns the bucket. Set the key the first time only; it is kept in Secrets Manager:
AWS_PROFILE=... AISSTREAM_API_KEY=... ./deploy/deploy.sh
The script builds the image, pushes it to ECR and deploys the CloudFormation stack
ais-3d-port in us-east-1, in the default VPC's subnets (or the only VPC, if none is
default; otherwise set VPC_ID and SUBNET_IDS). Run it again after changing the code.
Logs are in the /3d-port/ais, /3d-port/tide, /3d-port/storm-surge and /3d-port/twin log
groups. A
deploy stops the old AIS task before starting the new one, so two consumers never write at
once; positions are missed for about a minute. Stop any local consumers writing to the same
S3 prefixes.
The twin app. Vercel serves the page but can't run twin_app.py or forward
websockets, so:
twin_app.py runs in Docker on an EC2 instance with an Elastic IP, started by a
systemd unit (twin) that pulls the image named in the SSM parameter /3d-port/twin-image.https:// and wss:// without a domain
of our own. It forwards the feed paths to port 8001 and everything else to 8000. The
instance accepts connections only from CloudFront./api/* requests to CloudFront, through the
rewrite in scripts/twin_app/vercel.json. /api/config gives the feeds' address,
wss://<distribution>.cloudfront.net (twin_app.py --feed-url, or FEED_URL), and the
page opens them there.The script ends by printing the CloudFront URL, which also serves the whole app by itself. It
writes vercel.json with that URL; commit and push it so Vercel redeploys the page with the
rewrite. This assumes the Vercel project's root directory is scripts/twin_app.
After the first deploy, the script restarts twin_app.py on the new image through SSM Run
Command. A newer Amazon Linux AMI replaces the instance on the next deploy, which is how it
gets OS updates; the app is down for a few minutes then. For a shell on the instance, run
aws ssm start-session --target <instance id>; it has no SSH key.
Behind HTTPS elsewhere, put a TLS proxy (nginx, Caddy and so on) in front of both ports,
8000 and 8765, and let it pass websocket upgrades through on 8765. Pass --feed-url if the
feeds aren't at wss:// on the page's own host and port 8765.
Tests:
python -m unittest
The app is plain HTML and JavaScript on a single CesiumJS 1.146 viewer, loaded from a CDN.
Switching tabs changes the scene between 2D and 3D. OpenStreetMap is the base map, so no Cesium
ion token is needed. Open a panel directly with ?tab=live, ?tab=replay or ?tab=flood.
z_bottom) is at or below the water. Hovering a building shows its ground elevation, its
height and how far it is above or below the water.load_rasters.py loads the 1 m DTM (downloading it if needed) and
mosaics the three NONNA-10 tiles, both clipped to the bounding box of extent.gpkg. It shifts NONNA-10 from chart datum to CGVD2013 (CD_TO_CGVD2013 = -1.41 m) and
resamples it onto the DTM grid. The merge takes bathymetry wherever NONNA-10 has data and the DTM
everywhere else. It also defines DATA_DIR.
export_terrain.py writes the merged raster as a Cloud Optimized GeoTIFF, plus Cesium quantized-mesh terrain tiles for levels 0–16 with vertex normals. Heights are shifted from CGVD2013 to the WGS84 ellipsoid by a constant −20.27 m.
fetch_buildings.py reads the national NRCan building
GeoParquet with DuckDB, from data/auto_building_opti_2.parquet if it is there and otherwise
over HTTP from BUILDINGS_URL. It keeps the footprints that intersect extent.gpkg and writes
them to building_proj.gpkg in EPSG:2961. --source reads another copy. Three of the
footprints are about 150 m tall with small footprints at the Fairview Cove container terminal,
probably cranes that the automatic extraction took for buildings.
extrude_buildings.py extrudes each footprint from its ground
elevation (elevmin) by its height (heightmax) into a closed solid in EPSG:2961.
export_buildings_3dtiles.py writes the solids as
3D Tiles 1.1, grouped into 1 km tiles. Each building is a pickable feature with its attributes
(feature_id, heightmax, elevmin, z_bottom, z_top, bldgarea). Each building's edges
are stored with CESIUM_primitive_outline, so Cesium can draw outlines.
ais_consumer.py subscribes to aisstream.io for the extent's bounding box and clips positions exactly to the extent polygon.
AIS_DIR/positions/.AIS_DIR/vessels.parquet.--compact: merges one day's position files into a single file and writes berth
occupancy to AIS_DIR/analysis_daily/: one row per vessel and berth, counting the clock
hours the vessel was moored in that berth (speed under 1 kn). On AWS, Lambda runs it as
compact_handler.AIS_DIR is DATA_DIR/ais, or the S3 prefix in the AIS_DIR environment variable
(storage.py). TIDE_DIR and FLOODING_DIR below work the same way.
ais_live.py sends each vessel's latest position from the last 10 minutes of position files, with its ship type, as a GeoJSON FeatureCollection whenever a new file appears.
tide_consumer.py polls the CHS IWLS API for station 00490 every
5 minutes. It stores the latest observed level (wlo), or the predicted level (wlp) when the
gauge has no recent data, in one Parquet file per day under TIDE_DIR/levels/. Each row has
ts, date, time, tide_level, series, qc_flag and fetched_at. A reading that's already
stored is skipped.
tide_live.py sends the latest tide reading with its trend:
rising, falling or steady, compared with the previous reading of the same series within
30 minutes.
storm_surge_consumer.py polls the MSC GeoMet
coastal-flooding-risk-index collection every 30 minutes.
FLOODING_DIR/risk_index/ only
when the set of features changes.latest.json: records the current snapshot, or that there is no risk, after every poll.storm_surge_live.py sends the current snapshot's features where storm surge drives the risk and the forecast period hasn't ended, as GeoJSON. Each message includes the time of the last poll.
twin_app.py serves the app, DATA_DIR/terrain/ and
DATA_DIR/buildings_3dtiles/ over HTTP, and runs the three feeds. It also has two JSON routes:
/api/config gives the extent, the datum offsets, the feed port and paths, and where the page
loads terrain and buildings (layers): the local folder if built, else the S3 copy (the
bucket allows cross-origin GETs), else null./api/replay?days=7 gives berth occupancy per day from the analysis files.| Source | Used for | Heights |
|---|---|---|
| Lidar DTM (1 m) | land terrain, building ground elevation | CGVD2013 |
| CHS NONNA-10 | bathymetry | chart datum, shifted −1.41 m to CGVD2013 |
| NRCan automatically extracted buildings | 3D buildings | CGVD2013 |
| aisstream.io | vessel positions and ship types | – |
| CHS IWLS API | tide level, station 00490 Halifax | chart datum |
| MSC GeoMet OGC API | coastal flooding risk index | – |
To compare tide levels with the terrain and buildings, everything is brought to one height reference:
The project started in a Claude.ai chat that wrote the raster loader, and was then built in three Claude Code sessions (Claude Opus 5.5, October 2026): terrain and buildings, then the AIS services, then the remaining services and the app.
load_rasters.py was started in the Claude.ai web app, with these prompts:
Write Python code that loads the dtm_proj.tif and nonna10_proj.tif GeoTIFF data.
Result: load_rasters.py. It reads both rasters as masked NumPy arrays, with NoData cells
masked out, and keeps their georeferencing (EPSG:2961, 10 m grid). It uses rasterio when
available and falls back to Pillow + NumPy otherwise. It prints a summary of each raster and
checks that the two share the same grid. The 10 m dtm_proj.tif was later replaced by the 1 m
DTM (see session 1, prompt 5).
Set the NONNA-10 NoData to -32767.
The first attempt wrote a converted copy of the GeoTIFF, which wasn't wanted, so the request was clarified:
No, I don't want that. In the load_rasters.py code add a line that adjusts the NONNA-10 NoData value to -32767 which is the same as the DTM.
Result: a set_nodata() method on the Raster class, called on load. It changes NONNA-10's
NoData from -9999 to -32767 in memory and leaves the source file unchanged.
The vertical datum for the NONNA-10 is the local Chart Datum and DTM is in CGVD2013. The NONNA-10 data needs to be adjusted +1.44 m so it will also be in CGVD2013.
Result: the constant CD_TO_CGVD2013 = 1.44, applied to valid NONNA-10 cells on load, so the
bathymetry and the DTM share the same vertical datum (CGVD2013). The offset was later corrected
to −1.41 m (see session 3, prompt 4).
Write a test that the difference between the original NONNA-10 data and the adjusted NONNA-10 data is 1.44m
Result: the datum shift moved into adjust_nonna(), plus test_load_rasters.py. The shift
was later corrected to −1.41 m (see session 3, prompt 4).
change the data path prefix in the load_rasters.py to "/Users/michaelkieser/projects/3d_port/"
Result: DATA_DIR, which every other script now imports.
merge the dtm and nonna10 together as one raster
Result: merge_rasters() and write_raster(), writing dtm_nonna10_merged.tif.
can you output the merged raster as a COG and Cesium quantized-mesh terrain
Result: export_terrain.py.
Substitute the 10 meter resolution NONNA10_proj.tif image with the 1 meter resolution dtm_1m_utm20_w_4_94.tif
Claude Code pointed out that the 1 m file is a land DTM with no bathymetry, and asked whether it should replace the 10 m DTM instead. The answer was yes.
Result: the 1 m DTM replaced dtm_proj.tif, NONNA-10 is resampled onto the 1 m grid
(resample_to()), and the terrain goes to level 16.
Yes, prior to writing the data in Cesium, adjust it to the WGS84 ellipsoid, but lowering it by 20.270 m, then write it as a Cesium quantized-mesh terrain.
Result: CGVD2013_TO_WGS84 = -20.270 in export_terrain.py, applied to the terrain tiles only.
Read the /Users/michaelkieser/projects/3d_port/building_proj.gpkg file in, and convert the building polygons into 3D shapes using the elevmin attribute as the bottom of the buildings and the heightmax attribute as the top of the buildings.
Result: extrude_buildings.py, writing building_3d.gpkg.
Yes, lower the buildings by the same 20.27 m and export the buildings as Cesium 3D Tiles.
Result: export_buildings_3dtiles.py, checked with Cesium's 3d-tiles-validator
(0 errors, 0 warnings).
Can you summarize the prompts in this Claude Code tab so I can include them in a README.md
Result: this section, and the terrain and buildings decisions below.
For my Port and Coastal Operations Twin, I want to build a small service that turns raw AIS positions into port operations signals. I'll need a Python websocket consumer that filters the AIS data to the project extent (/Users/michaelkieser/projects/3d_port/extent.gpkg) and stores the positions as GeoParquet with DuckDB.
Result: ais_consumer.py and test_ais_consumer.py. No AIS feed was named, so Claude Code
used aisstream.io.
Can you write a function within the ais_consumer that merges each day's files into one and saves it in a separate folder.
Result: compact_day() and the --compact [YYYY-MM-DD] option, writing
DATA_DIR/ais/positions_daily/.
Can you write a analyze_day function that would run after the compact_day function. The function analyzes each days positions.parquet file and determines the berth it occupied by name, the dwell time in whole hours that day the vessel occupied the berth, and the average x and y coordinates while in the berth. The polygon berth data, with a "name" attribute, is here /Users/michaelkieser/projects/3d_port/berth.gpkg. Store the "mmsi", "ship_name", "berth_name", "dwell_time", "x" and "y" in a daily analysis.parquet file.
Result: analyze_day(), writing DATA_DIR/ais/analysis_daily/; --compact now runs both.
Claude Code found that one berth (AYC) was invisible to DuckDB because it was an unsaved edit
in QGIS; it appeared once QGIS saved the file.
Change the PositionStore to store the original lon/lat coordinates as EPSG:4326 rather than x/y. Also change the analyze_day so the coordinates are stored as lon/lat coordinates.
Result: positions and the analysis store lon/lat, and the geometry is labelled EPSG:4326.
Claude Code asked what to do with the 58 files already written with x/y; the answer was to
leave them.
Can you write a function that can push live vessel positions from the most recent .parquet file to a browser over a websocket?
Result: ais_live.py and test_ais_live.py, sending each vessel's latest position as GeoJSON.
Yes, read the last few files instead of one, to catch those moored ships that report every 3 minutes.
Result: the feed reads the last 10 minutes of files, which took the map from 6 to 45 vessels.
I have deleted all the .parquet files that had x/y coordinates, so you don't have to worry about them anymore.
For my Port and Coastal Operations Twin, I want to build a service that stores the current tide level using the CHS IWLS API. I'll need a Python websocket consumer that pulls the tide level for CHS station 00490 every 5 minutes and stores the date, time and tide level. Then can you write a function that can push the most recent tide level to a browser over a websocket.
Result: tide_consumer.py, tide_live.py and their tests.
For my Port and Coastal Operations Twin, I want to build a service that stores the current storm surge published by the MSC GeoMet-OGC-API (collections/coastal-flooding-risk-index/items). I'll need a Python websocket consumer that pulls the storm surge within the project extent (/Users/michaelkieser/projects/3d_port/extent.gpkg) every 30 minutes and stores the storm surge as GeoParquet. Then can you write a function that can push the most recent storm surge to a browser over a websocket.
Result: storm_surge_consumer.py, storm_surge_live.py and their tests.
Okay, so now I want to bring everything together into a CesiumJS app. I want the app to have three panels, called "Live", "Replay" and "Flood". The first panel, called "Live", plots in 2D the current vessels using the latest ais-positions Geoparquet file as oriented icons coloured by type, and I also want to show a the current tide level using the latest tide-levels Geoparquet file with a arrow of upward or downward trend in the tide. The second panel, called "Replay", plots in 2D each vessel location based on the ais-analysis_daily data for the past week and a timeslider. Each vessel location will be styled with a circle coloured green to red based on the shorter to longer dwell time, with on hover info for each vessel. The third panel, call "Flood", plots in 3D the topo-bathy terrain-tiles and buildings_3dtiles, with a water plane at the current tide level. I also want to show the current maximum storm surge height using the latest coastal_flooding data. The user should be able to adjust a slider to raise and lower the water plane displaying the height above the current tide level. The 3D buildings should be styled black with grey outline and those that are touched by the water plane styled red with grey outline.
Result: twin_app.py and twin_app/, ship types in the AIS consumer and feed, the tide trend,
building outlines, and the terrain winding fix. Claude Code checked each panel in headless
Chrome against the real data.
Claude Code found that the chart datum offset for NONNA-10 had the wrong sign and asked whether to fix it.
Yes, make that change.
Result: CD_TO_CGVD2013 changed from +1.44 m to −1.41 m, and the terrain rebuilt.
These are the choices Claude Code made where the request and the data didn't line up, or where there was more than one reasonable approach.
Terrain and buildings
heightmax is a height above ground. heightmax averages 7.9 m and elevmin 46 m, so
using heightmax as the roof elevation would put 30,129 of 31,389 roofs below their bases.
Buildings run from elevmin to elevmin + heightmax. One building with a height of 0 or less
is dropped.AIS
compact_day merges each day into one file sorted by vessel and time. It keeps the originals
and writes through a temporary file, so readers never see a half-written file.analysis_daily/, not next to
positions.parquet, so a **/*.parquet read of positions never picks them up.geom in
extent.gpkg, geometry in berth.gpkg), so analyze_day finds it by type.Tide
series column records which one each row is.Storm surge
latest.json. DuckDB can't write a valid GeoParquet file with no rows, so "no risk" is
recorded in latest.json instead of in an empty file. This also means a cancelled warning
disappears straight away instead of lingering until it expires.App
z_bottom)
is at or below the water plane.Fixes to the existing pipeline
export_terrain.py wrote every terrain triangle clockwise, but
quantized-mesh needs counter-clockwise, so Cesium culled the terrain surface. The winding was
reversed and the tiles regenerated.CESIUM_primitive_outline
data in the tiles, so the exporter now writes each building's edges and the tiles were
regenerated.Result: the code for reading old and new files together was removed.
what does this mean "INFO connection rejected (426 Upgrade Required)"
Answer: a plain HTTP request, such as opening the websocket address in the browser's address bar, reached the websocket server.
Yes, create a small HTML page that connects and plots the vessels on a map.
Result: ais_live.html, a Leaflet map served at http://localhost:8765 by ais_live.py. It
was later removed in favour of the app's Live panel (see session 3, prompt 6).
And lastely, can you summarize the prompts in this Claude Code tab so I can include them in a README.md
Result: this section, the Claude.ai section above (from a summary of that chat), and the AIS decisions below.
Can you connect to my Github account (https://github.com/mkieser) and push this to a new repo called twin_port_vibecoded.
Let's remove the ais_live.html
Result: the standalone Leaflet map was removed; the Live panel replaces it.
Can you populate the README file, that summarizes the creation of this repo using Claude Code, including the prompts, the decisions, the different scripts, their functions, how to run the scripts and the functionality in the app.
Result: this README.