projectharrison.org · saurabhn.com
Simulator that converts real-time AIS derived vessels into MASS vessels. Autonomy is done through network graphs, which very approximately matches incipient MASS efforts. User is able to control own vessel. Just a fun project with no particular goal in mind.
uv syncassets/shapefiles/World/goas_v01.shp (122 MB) is not stored in git. Place all component files in assets/shapefiles/World/.
Direct download (Google Drive): goas_v01.shp — Google Drive
Original source (VLIZ Global Oceans and Seas v1.0):
https://www.vliz.be/en/imis?dasid=5168
Required files: goas_v01.shp, goas_v01.shx, goas_v01.dbf, goas_v01.prj, goas_v01.cpg
On first launch, if no key is found, a dialog prompts for the key before the port picker opens. You can also update the key at any time using the AIS Key button in the simulation sidebar (green dot = configured, red dot = missing).
The key is saved to ~/Library/Application Support/OpenAVS/config.json and read on every subsequent launch. It can also be set via the SAURAHBN_AIS environment variable or placed in ../react/.env.
uv run python main.pyA self-contained OpenAVS.app and installer DMG can be built with a single script. No Python installation is required on the target Mac.
bash build_mac.shThis will:
- Install PyInstaller into the project virtualenv
- Convert
assets/images/thumbnail.png→assets/images/OpenAVS.icns(app icon) - Bundle the app — Python runtime, all dependencies, the shapefile, ports database, and images — into
dist/OpenAVS.app - Compress it into
dist/OpenAVS.dmg(~116 MB)
| File | Size | Description |
|---|---|---|
dist/OpenAVS.app |
~179 MB | Standalone macOS app bundle |
dist/OpenAVS.dmg |
~116 MB | Compressed disk image for distribution |
- Double-click
OpenAVS.dmgand dragOpenAVS.appto Applications - First launch only: right-click → Open to bypass the Gatekeeper warning (the app is unsigned — no Apple Developer certificate)
- Subsequent launches work normally from Spotlight or the Dock
On first launch, if no key is found, a dialog prompts for it automatically. Enter your Saurabhn key and click Save & Continue — it is written to:
~/Library/Application Support/OpenAVS/config.json
You can update the key at any time via the AIS Key button in the simulation sidebar (bottom-right, above the Area Selection button). A green dot means a key is configured; red means it is missing. Without a key the simulation runs but live AIS vessels do not appear.
The .app bundle is read-only. All runtime writes go to:
~/Library/Application Support/OpenAVS/
├── tile_cache/ # downloaded map tiles — persists between runs
├── database/stream # per-run vessel state CSV — cleared on startup
└── config.json # API key (user-created)
Tile cache persists across app updates, so previously visited ports load instantly.
Dark-themed port picker. Left column: 3-line usage guide, search box, and port list (type city or country to filter; click or press Enter to select). Right panel card (centered in the right half) contains:
- CONTROLS — vessel control key reference with drawn polygon arrows
- SIM SPEED — 6 speed presets (¼× ½× 1× 2× 4× 8×), also adjustable with
[/]during simulation - MAP ZOOM —
−/+buttons; range 8–17; controls the tile zoom level of the chart assembled at load time
Speed and zoom selections persist if you return to this screen from inside the simulation.
Footer: projectharrison.org · powered by saurabhn.com
Four animated progress bars:
- Chart tiles — CartoDB base download/composite → OpenSeaMap nav-mark overlay → shapefile coastline outlines → water-centering pass
- Vessel positions — live AIS fetch with shimmer while in-flight
- Sea network — 50×40 routing graph built from pixel-sampled chart
- Route planning — parallel A* route calculation for each vessel (8-thread pool)
Begins when all steps complete. The player spawns near the selected port in open water (spiral search if the default spawn is on land).
| Key | Action |
|---|---|
← / → |
Course ±15° |
↑ / ↓ |
Speed ±2 knots |
[ / ] |
Sim speed slower / faster |
SPACE |
Pause / Resume |
ENTER |
Quit simulation |
F12 |
Save screenshot to screenshots/frame.png |
| Back button (sidebar) | Return to port selection |
The chart is rendered once at startup into a static pygame.Surface and never redrawn. It is built bottom-up:
| Layer | Source | Notes |
|---|---|---|
| Sea fill | SEA_COLOR (168, 204, 222) |
Fallback base |
| CartoDB base | basemaps.cartocdn.com/rastertiles/voyager_nolabels |
Full-color raster tiles, cached locally |
| OpenSeaMap nav marks | tiles.openseamap.org/seamark |
Transparent PNG overlay — buoys, lights, traffic separation schemes |
| Shapefile coastlines | assets/shapefiles/World/goas_v01.shp — VLIZ GOAS v1.0 |
Ocean basin polygons drawn as aalines outlines on top |
Tiles are downloaded in parallel (up to 16 threads) and cached in assets/tile_cache/. Subsequent runs at the same location load instantly from cache.
After the initial chart render, _recenter_on_water() samples every 8th pixel, computes the centroid of all sea-colored pixels, and shifts the chart origin toward that centroid — capped at 45% of the viewport. If the shift exceeds 20px, the chart is rebuilt from cached tiles with the new origin. This prevents port selections that open on mostly-land views (e.g., an inland port surrounded by a peninsula).
_chart_is_sea(lat, lon) pixel-samples the rendered chart_bg surface using the equirectangular projection. A pixel is sea if b > 140 and b > r and (b − r) > 20. This is used by the routing graph builder, the vessel shore guard, and the TargetBot spawn search.
| Vessel | Color | Behavior |
|---|---|---|
| AUG/V TargetBot | Red | Player-controlled |
make_sim() in main.py constructs the vessel list. supporting.segregate() spaces them apart before the simulation begins. TargetBot spawns at the selected port; if that point is on land, a spiral outward search finds the nearest sea pixel.
Source: Saurabhn AIS API — GET /v1/positions/latest
GET https://api.saurabhn.com/v1/positions/latest
?min_lat=…&max_lat=…&min_lon=…&max_lon=…
X-API-Key: <SAURAHBN_AIS>
- Bounding box is the exact visible viewport — no margin
- API cap is 2000 vessels per call; if hit (
≥1990results), the list is spatially subsampled by half (results[::2]) - Fetched once per area selection in a background daemon thread
- Each vessel record stores:
name,mmsi,imo,lat,lon,speed,course,heading,flag,is_sanctioned
| Condition | Color | Behavior |
|---|---|---|
| Sanctioned | Red | Static dot at reported position |
| Speed ≤ 5 kts | Amber | Static dot — anchored/moored |
| Speed > 5 kts | Dark blue | Dead-reckoned — advances each sim tick |
Vessels with speed > 5 kts are extracted into the live_ais list. Every sim tick they advance one minute using the navigation engine and the multi-layer behavior system described below. Their trail dots accumulate on screen; the leading dot is drawn to display (ephemeral, never accumulates).
Each live vessel leaves a dot every simulation tick:
- Every 6 minutes — colored dot (dark blue or red) at 2px
- All other ticks — 1px grey dot
This is the core of the simulation. Every live AIS vessel runs a four-layer autonomous navigation stack.
At load time, a 50×40 grid of sea nodes is sampled over the viewport using _chart_is_sea(). Nodes are connected in 8 directions with nautical-mile edge costs. A* pathfinding (graph.py) produces a waypoint list from each vessel's starting position toward a destination projected in its reported course direction.
Route computation is parallelized across 8 threads. Progress is shown on the loading screen.
Each vessel tracks:
waypoints— list of (lat, lon) path nodes from A*wp_idx— index of the current target waypointnetwork_heading— bearing from current position to current waypoint; updated every tick when within ~0.5 nm of a waypoint node
Each tick a small random walk bias is added to the rendered heading:
heading_bias = heading_bias * 0.98 + gauss(0, 0.5)
heading_bias = clamp(heading_bias, −2.5°, +2.5°)
The bias is render-only — it is never fed back into position integration.
A Shapely STRtree spatial index of all live AIS vessel positions is rebuilt every 2 seconds. Every 1 second, each vessel queries the index for neighbors within 2 nm. For each close neighbor, flat-earth CPA/TCPA is computed from relative position and velocity vectors:
tcpa = −(relpos · relvel) / |relvel|²
cpa = |relpos + relvel × tcpa|
If cpa < 0.3 nm and 0 < tcpa < 10 min, the vessel applies a starboard alteration:
alter_deg = min(45°, 10° + (0.3 − cpa) × 30°)
The alteration remains active (alter_until_clear) until CPA improves above the safe threshold.
Every 30–90 seconds, there is a 3% probability that a vessel makes a random heading change of ±25° lasting 60–300 seconds.
Each tick, intended_heading is built from:
network_heading(from A* waypoints) — baseline- Override with random captain alteration (if active)
- Override with traffic avoidance alteration (if active — highest priority)
The vessel's actual heading slews toward intended_heading at a maximum rate of 8°/tick.
Before each position advance, the computed next position is checked against _chart_is_sea(). If it is land, a cascade of fallbacks runs in order:
- Network heading — fall back from the modified intended heading to the raw A* heading
- Next waypoint bearing — skip to the next waypoint node and compute a bearing from there; if clear, advance the waypoint index and use that heading
- 360° sweep — try headings at ±30°, ±60°, ..., ±180° from network heading in order of proximity; use the first that leads to sea
- Surrounded check — sample 8 cardinal/intercardinal directions; if ≤2 are open, the vessel goes static. If >2 are open but all sweep angles failed, freeze position for one tick and advance the waypoint index
Vessels that go static are moved to static_ais and rendered as color-coded fixed dots for the remainder of the session.
Each simulation vessel writes a CSV row to database/stream every tick:
voyage_id, vessel_name, timestamp, lat, lon, course, speed
The aware() method reads the last N rows and dead-reckons all vessel pairs forward to find minimum separation. Results are stored as cpa[] and tcpa[] lists on each vessel object.
Every 30 simulation ticks, _compute_live_cpa() projects each live AIS vessel and TargetBot forward 60 minutes and finds the minimum separation distance. Only vessels within 25 nm of TargetBot are evaluated. Results are sorted by CPA and displayed in the sidebar AIS Contacts section.
All positional math uses real maritime formulas:
| Function | Method |
|---|---|
| Position advance | Mid-latitude dead reckoning |
| Great circle distance | geopy.geodesic in nautical miles |
| Rhumb-line distance | Haversine formula |
| True bearing | atan2 on lat/lon deltas |
| Chart projection | Equirectangular: x = ox − (origin_lon − lon) × NS, y = oy + (origin_lat − lat) × NS |
NS (nautical-miles-to-pixels scale) is NS_BASE × 2^(zoom−12) where NS_BASE = 1800 × S and S is the DPI scaling factor.
Behavior classes are named after Pac-Man ghost AI (Japanese originals):
| Class | Japanese | Meaning | Status |
|---|---|---|---|
Oikake |
追いかけ | "to pursue" | Implemented |
Machibuse |
待ち伏せ | "to ambush" | Defined, unassigned |
Otoboke |
おとぼけ | "feigning ignorance" | Defined, unassigned |
Oikake computes the bearing from the chasing vessel to TargetBot and steers toward it with aggression factor 12.5.
pygame-ce on macOS uses a Metal-backed hardware display surface. Blitting SRCALPHA surfaces directly onto the hardware surface produces black rectangles instead of transparency.
Two-surface rendering:
display— hardware surface, never drawn to directlyscreen— plain software surface, all rendering targets thisdisplay.blit(screen, (0,0))+pygame.display.flip()before every frame push
Leading-point circles for simulation vessels and AIS vessels are drawn directly to display after the screen blit and before flip() — this keeps them ephemeral so they do not accumulate on the persistent vessel trail.
Redrawn every simulation tick. Sections top-to-bottom:
- Controls — keyboard shortcut reference with drawn polygon arrows
- Status —
RUNNING(green) /PAUSED(red) - Vessel cards (up to 3) — position, course, speed, CPA/TCPA per vessel pair. Player vessel highlighted with blue accent bar and
YOUbadge. CPA values turn red below 1.0 nm - AIS Contacts — live AIS vessels within 25 nm of TargetBot, sorted by CPA (closest first). Shows vessel name, CPA distance, and TCPA in minutes. Shows placeholder cards with
Calculating…until the first CPA compute cycle (30 ticks). CPA turns red below 1.0 nm - AIS Key button — opens the API key entry dialog; green dot = key configured, red dot = missing. Triggers an immediate AIS re-fetch after saving
- Area Selection button — returns to port search without restarting pygame
Flat CSV written every simulation tick. Each row:
voyage_id, vessel_name, timestamp, lat, lon, course, speed
- Truncated at startup each run (prevents I/O lockup from accumulated history)
- Used exclusively for inter-vessel CPA awareness of the simulation vessels
- Not persisted between runs
ShipSimulator/
├── main.py # Entry point — make_sim() factory, calls interface.run()
├── sim/
│ ├── autonomous.py # Vessel class — position, dead reckoning, CPA, stream I/O
│ ├── behavior.py # Autonomous behavior classes (Oikake/Machibuse/Otoboke)
│ ├── navigation.py # Maritime math: DR, haversine, great circle, bearing, projection
│ ├── interface.py # pygame render loop, port picker, loading screen, AIS behavior stack
│ ├── graph.py # Ocean routing graph — 50×40 sea-node grid, A* pathfinding
│ ├── tiles.py # Chart builder — CartoDB + OpenSeaMap + shapefile coastlines
│ ├── ais_vessels.py # Live AIS fetch (Saurabhn bbox API), background thread, vessel store
│ ├── locations.py # Legacy named areas (superseded by ports.json picker)
│ └── supporting.py # Voyage ID generator, vessel segregation utility
├── capture.py # Headless screenshot renderer (splash / loading / main)
├── data/
│ ├── layer.geojson # NGA worldwide navigation lights (11,381 features)
│ └── ports.json # World port database (~4,700 unique ports) — github.com/tayljordan/ports
├── assets/
│ ├── images/m.png # Window / dock icon
│ ├── shapefiles/World/ # VLIZ GOAS v1.0 shapefile (download separately — 122 MB)
│ └── tile_cache/ # Cached map tiles — downloaded on first run per location
├── database/
│ └── stream # Per-tick vessel state CSV, cleared on startup
├── pyproject.toml # uv project — Python 3.13, pinned dependencies
└── .venv/ # Virtual environment
| Package | Version | Purpose |
|---|---|---|
pygame-ce |
≥ 2.5.7 | Rendering, event loop, input, font, image |
geopy |
≥ 2.4.1 | Geodesic (great circle) distance |
requests |
≥ 2.34.0 | CartoDB + OpenSeaMap tile download, Saurabhn AIS API |
pyshp |
≥ 3.0.3 | Shapefile reader (no GDAL/geopandas dependency) |
shapely |
≥ 2.1.2 | STRtree spatial index for CPA traffic avoidance |
Managed with uv. Python 3.13.
- GOAS shapefile resolution — 10 ocean basin polygons; coarse at zoom 12. CartoDB tiles provide the accurate land/sea rendering; the shapefile adds outline strokes only.
- A grid resolution* — 50×40 nodes over the viewport. Vessels clipping narrow passages is possible; the shore guard cascade handles recovery.
- AIS 2000-vessel cap — Saurabhn API returns at most 2000 positions per bbox. Dense areas are spatially subsampled by half when the cap is hit.
- AIS fetched once — no periodic refresh. Dead reckoning continues indefinitely from the snapshot.
- CPA range limit — live AIS vessels beyond 25 nm of TargetBot are tracked but not evaluated for CPA.
