# ═══════════════════════════════════════════════════════════════════════════════
# FSL Project Makefile
#
# Command groups:
# (no prefix) Setup utilities — compile-proto, download-data
# docker-* Local Docker dev — single-machine, all containers on one host
# native-* Local Python dev — no Docker, direct process execution
# dist-* Distributed — VPS server + Raspberry Pi clients
# eval-* Evaluation — test-set metrics and batch reports
# plot-* Visualisation — training curves, confusion matrices
# matrix* Experiment matrix— run the 14-scenario ablation suite
# ═══════════════════════════════════════════════════════════════════════════════
.PHONY: help compile-proto download-data \
docker-run docker-run-single docker-build docker-clean \
native-run native-run-single native-server native-clients native-check-port native-clean native-clean-results native-reset \
dist-build dist-deploy dist-deploy-check dist-start dist-logs \
dist-sync-config dist-server-restart dist-load-image dist-load-image-local dist-clean-results dist-clean-server \
dist-restart \
eval-latest eval-session eval-batch \
plot-latest plot-session plot-confusion \
matrix matrix-dry-run
# ─── Variables ────────────────────────────────────────────────────────────────
# Read num_clients straight from config so it stays in sync automatically
DEFAULT_NUM_CLIENTS := $(shell grep "num_clients:" config.yaml | awk '{print $$2}')
NUM_CLIENTS ?= $(DEFAULT_NUM_CLIENTS)
# Config file to deploy to VPS + Pis.
# Defaults to config.yaml for manual runs.
# set to a merged scenario config by run_experiment_matrix.py for matrix dist runs.
DEPLOY_CONFIG ?= config.yaml
# Docker image coordinates
REGISTRY ?= cindyncl26
CLIENT_IMAGE ?= $(REGISTRY)/fsl-client:latest
IMAGE_TAG ?= latest # Override: make dist-start IMAGE_TAG=sha-abc1234
# Remote hosts
VPS_USER ?= ubuntu
VPS_HOST_DEPLOY ?= 51.254.207.168
################################################################################
# Ansible 修改讀取預設值
################################################################################
ANSIBLE_INV ?= ansible/inventory.ini
# Network
SERVER_HOST ?= 0.0.0.0
SERVER_PORT ?= 50051
# Devices (cpu / cuda / mps)
SERVER_DEVICE ?= cpu
CLIENT_DEVICE ?= mps
# Timing
STARTUP_TIMEOUT ?= 60 # Seconds to wait for server readiness
# Analysis
PLOT_DEVICE ?= cpu
AUTO_PLOT ?= 0 # Set to 1 to auto-plot after a run
# Python / uv
UV_CACHE_DIR ?= .uv-cache
UV := UV_CACHE_DIR=$(UV_CACHE_DIR) UV_LINK_MODE=copy uv
PYTHON ?= $(if $(wildcard .venv/bin/python),.venv/bin/python,python)
# Terminal colours (used in log multiplexer)
SERVER_COLOR := \033[1;34m
RESET_COLOR := \033[0m
# ─── Help ─────────────────────────────────────────────────────────────────────
help:
@echo ""
@echo "Usage: make <target> [VAR=value ...]"
@echo ""
@echo "── Setup ──────────────────────────────────────────────────────"
@echo " compile-proto Recompile fsl.proto → Python stubs"
@echo " download-data Fetch weather data into dataset/processed/"
@echo ""
@echo "── Local Docker (docker-*) ─────────────────────────────────────"
@echo " docker-run [NUM_CLIENTS=N] Build + start server & N clients in Docker"
@echo " docker-build Build local amd64 image (no push)"
@echo " docker-clean Stop & remove all FSL containers"
@echo ""
@echo "── Local Native (native-*) ─────────────────────────────────────"
@echo " native-run [NUM_CLIENTS=N] Start server then all clients as Python processes"
@echo " native-server Start only the server process"
@echo " native-clients Start only the client processes (server must be up)"
@echo " native-clean Kill all native server/client processes"
@echo ""
@echo "── Distributed Pi (dist-*) ─────────────────────────────────────"
@echo " dist-build [IMAGE_TAG=x] Build amd64+arm64 image and push to Docker Hub"
@echo " dist-sync-config Push config.yaml to VPS + all Pis"
@echo " dist-server-restart SSH → VPS: stop + recreate server container"
@echo " dist-load-image Save image from VPS → Mac → all Pis (offline)"
@echo " dist-load-image-local Build arm64 image on Mac → push to all Pis (no VPS/DockerHub needed)"
@echo " dist-deploy [IMAGE_TAG=x] Ansible: deploy image to all Pis (Tailscale)"
@echo " dist-logs View real-time logs from the VPS server"
@echo " dist-clean-results Erase ALL results on ALL Pi clients"
@echo " dist-clean-server Erase results and weights on the VPS"
@echo " dist-restart Nuclear restart: clean all + restart server"
@echo " dist-deploy-check Ansible dry-run: preview changes without applying"
@echo " dist-start [IMAGE_TAG=x] Full experiment: sync config → restart server → deploy Pis"
@echo " dist-fetch-server Fetch results/bestweights from VPS (with periodic)"
@echo " dist-fetch-clients Fetch results/bestweights from all Pis (with periodic)"
@echo " dist-fetch-all Fetch results/bestweights from both VPS and Pis (with periodic)"
@echo ""
@echo "── Evaluation (eval-*) ─────────────────────────────────────────"
@echo " eval-latest Evaluate the most recent checkpoint session"
@echo " eval-session SESSION=<id> Evaluate a specific session"
@echo " eval-batch [FORCE_THRESHOLD=0.34] [REPORT_TAG=tag] Batch evaluation"
@echo ""
@echo "── Plots (plot-*) ──────────────────────────────────────────────"
@echo " plot-latest Training curve + server metrics for latest session"
@echo " plot-session SESSION=<id> Same for a specific session"
@echo " plot-confusion SESSION=<id> Confusion matrices for a specific session"
@echo ""
@echo "── Experiment Matrix (matrix*) ─────────────────────────────────"
@echo " matrix [ONLY=M01,M02] [MAX_RUNS=N] Run ablation scenarios"
@echo " matrix-dry-run Print the run plan without executing"
@echo ""
# ═══════════════════════════════════════════════════════════════════════════════
# SETUP
# ═══════════════════════════════════════════════════════════════════════════════
# Recompile proto/fsl.proto into proto/fsl_pb2*.py — only needed after editing the .proto
compile-proto:
$(UV) run python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. proto/fsl.proto
@echo "fsl.proto compiled successfully."
# Download 3 years of hourly weather from Open-Meteo for all configured stations
download-data:
$(UV) run python -m src.data.data_download_openmeteo
# ═══════════════════════════════════════════════════════════════════════════════
# LOCAL DOCKER (docker-*)
# Single-machine simulation: server + N clients all run as Docker containers
# on the same host, communicating over a Docker bridge network.
# ═══════════════════════════════════════════════════════════════════════════════
# Build image for the local host architecture only (no push, for quick local tests)
docker-build:
docker build -f Dockerfile -t $(CLIENT_IMAGE) .
# Clean slate run: tear down any leftover containers, rebuild, then start everything.
# If SCENARIO_ID is set, runs a single scenario. Otherwise runs the full matrix.
docker-run:
ifeq ($(SCENARIO_ID),)
@echo "[MATRIX] No SCENARIO_ID set → running full experiment matrix (docker backend)..."
$(MAKE) matrix BACKEND=docker
else
$(MAKE) docker-run-single
endif
docker-run-single: docker-clean
@echo "Starting $(NUM_CLIENTS) clients in Docker (scenario=$(SCENARIO_ID))..."
docker compose build
SESSION_ID=$(SESSION_ID) SCENARIO_ID=$(SCENARIO_ID) docker compose up -d fsl-server
@for i in $$(seq 1 $(NUM_CLIENTS)); do \
echo "Starting client $$i..."; \
docker compose run -d --no-deps --name fsl-client-$$i -e CLIENT_ID=$$i -e SESSION_ID=$(SESSION_ID) -e SCENARIO_ID=$(SCENARIO_ID) fsl-client; \
done
@(docker logs -f fsl-server 2>&1 | awk \
'{printf "$(SERVER_COLOR)%-13s$(RESET_COLOR) | %s\n", "fsl-server", $$0; fflush()}') & \
for i in $$(seq 1 $(NUM_CLIENTS)); do \
case $$((($$i - 1) % 6)) in \
0) color="\033[1;32m" ;; 1) color="\033[1;33m" ;; \
2) color="\033[1;35m" ;; 3) color="\033[1;36m" ;; \
4) color="\033[1;31m" ;; 5) color="\033[1;37m" ;; \
esac; \
(docker logs -f fsl-client-$$i 2>&1 | awk \
-v p=$$(printf "fsl-client-%d" $$i) -v c="$$color" -v r="$(RESET_COLOR)" \
'{printf "%s%-13s%s | %s\n", c, p, r, $$0; fflush()}') & \
done; \
wait; \
if [ "$(AUTO_PLOT)" = "1" ]; then $(MAKE) plot-latest; fi
# Stop and remove all FSL containers and Docker networks
docker-clean:
docker compose down -v --remove-orphans
@echo "Cleaned up all FSL Docker containers and networks."
# ═══════════════════════════════════════════════════════════════════════════════
# LOCAL NATIVE (native-*)
# Run server and clients as plain Python processes on the local machine.
# No Docker overhead — ideal for rapid iteration and debugging.
# ═══════════════════════════════════════════════════════════════════════════════
# Start only the gRPC server; blocks until you Ctrl-C
native-server:
@echo "Starting native server on $(SERVER_HOST):$(SERVER_PORT) [device=$(SERVER_DEVICE)]"
@$(MAKE) native-check-port
PYTHONUNBUFFERED=1 \
FSL_DEVICE=$(SERVER_DEVICE) \
FSL_SERVER_HOST=$(SERVER_HOST) \
FSL_SERVER_BIND_HOST=$(SERVER_HOST) \
FSL_SERVER_PORT=$(SERVER_PORT) \
SESSION_ID=$(SESSION_ID) \
SCENARIO_ID=$(SCENARIO_ID) \
$(PYTHON) -u -m src.nodes.server_node
# Start all clients in parallel (server must already be running)
native-clients:
@echo "Starting $(NUM_CLIENTS) native clients → $(SERVER_HOST):$(SERVER_PORT) [device=$(CLIENT_DEVICE)]"
@pids=""; \
trap 'kill $$pids 2>/dev/null || true' INT TERM EXIT; \
for i in $$(seq 1 $(NUM_CLIENTS)); do \
case $$((($$i - 1) % 6)) in \
0) color="\033[1;32m" ;; 1) color="\033[1;33m" ;; \
2) color="\033[1;35m" ;; 3) color="\033[1;36m" ;; \
4) color="\033[1;31m" ;; 5) color="\033[1;37m" ;; \
esac; \
( \
PYTHONUNBUFFERED=1 CLIENT_ID=$$i \
FSL_DEVICE=$(CLIENT_DEVICE) \
FSL_SERVER_HOST=$(SERVER_HOST) \
FSL_SERVER_PORT=$(SERVER_PORT) \
SESSION_ID=$(SESSION_ID) SCENARIO_ID=$(SCENARIO_ID) \
$(PYTHON) -u -m src.nodes.client_node 2>&1 | \
awk -v p=$$(printf "client-%d" $$i) -v c="$$color" -v r="$(RESET_COLOR)" \
'{printf "%s%-13s%s | %s\n", c, p, r, $$0; fflush()}' \
) & \
pids="$$pids $$!"; \
done; \
wait $$pids; \
if [ "$(AUTO_PLOT)" = "1" ]; then $(MAKE) plot-latest; fi
# One-shot: start server, wait for it to be ready, then start all clients.
# If SCENARIO_ID is set, runs a single scenario. Otherwise runs the full matrix.
native-run:
ifeq ($(SCENARIO_ID),)
@echo "[MATRIX] No SCENARIO_ID set → running full experiment matrix (native backend)..."
$(MAKE) matrix BACKEND=native
else
$(MAKE) native-run-single
endif
native-run-single:
@echo "Starting native stack: server($(SERVER_DEVICE)) + $(NUM_CLIENTS) clients($(CLIENT_DEVICE)) [scenario=$(SCENARIO_ID)]"
@$(MAKE) native-check-port
@set -e; \
pids=""; \
trap 'kill $$pids 2>/dev/null || true' INT TERM EXIT; \
if [ -n "$(SCENARIO_ID)" ] && [ -z "$$FSL_CONFIG_PATH" ]; then \
_fsl_cfg=$$($(PYTHON) -m src.shared.resolve_scenario_config "$(SCENARIO_ID)"); \
if [ -n "$$_fsl_cfg" ]; then \
export FSL_CONFIG_PATH="$$_fsl_cfg"; \
echo "[scenario] Merged config for $(SCENARIO_ID) → $$_fsl_cfg"; \
fi; \
fi; \
( \
PYTHONUNBUFFERED=1 FSL_DEVICE=$(SERVER_DEVICE) \
FSL_SERVER_HOST=$(SERVER_HOST) FSL_SERVER_BIND_HOST=$(SERVER_HOST) \
FSL_SERVER_PORT=$(SERVER_PORT) \
SESSION_ID=$(SESSION_ID) SCENARIO_ID=$(SCENARIO_ID) \
$(PYTHON) -u -m src.nodes.server_node 2>&1 | \
awk -v c="$(SERVER_COLOR)" -v r="$(RESET_COLOR)" \
'{printf "%s%-13s%s | %s\n", c, "server", r, $$0; fflush()}' \
) & \
server_pid=$$!; pids="$$server_pid"; \
echo "Waiting for server at $(SERVER_HOST):$(SERVER_PORT) (timeout $(STARTUP_TIMEOUT)s)..."; \
ready=0; \
for _ in $$(seq 1 $(STARTUP_TIMEOUT)); do \
if $(PYTHON) -c \
'import socket,sys; s=socket.socket(); s.settimeout(1); s.connect((sys.argv[1],int(sys.argv[2]))); s.close()' \
"$(SERVER_HOST)" "$(SERVER_PORT)" >/dev/null 2>&1; then \
ready=1; break; \
fi; \
sleep 1; \
done; \
if [ "$$ready" -ne 1 ]; then \
echo "Server did not become ready in time. Aborting."; \
kill $$pids 2>/dev/null || true; exit 1; \
fi; \
for i in $$(seq 1 $(NUM_CLIENTS)); do \
case $$((($$i - 1) % 6)) in \
0) color="\033[1;32m" ;; 1) color="\033[1;33m" ;; \
2) color="\033[1;35m" ;; 3) color="\033[1;36m" ;; \
4) color="\033[1;31m" ;; 5) color="\033[1;37m" ;; \
esac; \
( \
PYTHONUNBUFFERED=1 CLIENT_ID=$$i \
FSL_DEVICE=$(CLIENT_DEVICE) \
FSL_SERVER_HOST=$(SERVER_HOST) \
FSL_SERVER_PORT=$(SERVER_PORT) \
SESSION_ID=$(SESSION_ID) SCENARIO_ID=$(SCENARIO_ID) \
$(PYTHON) -u -m src.nodes.client_node 2>&1 | \
awk -v p=$$(printf "client-%d" $$i) -v c="$$color" -v r="$(RESET_COLOR)" \
'{printf "%s%-13s%s | %s\n", c, p, r, $$0; fflush()}' \
) & \
pids="$$pids $$!"; \
done; \
wait $$pids; \
if [ "$(AUTO_PLOT)" = "1" ]; then $(MAKE) plot-latest; fi
# Guard: fail early if the server port is already occupied
native-check-port:
@if lsof -iTCP:$(SERVER_PORT) -sTCP:LISTEN -n -P >/dev/null 2>&1; then \
echo "Port $(SERVER_PORT) already in use. Run 'make native-clean' first."; \
lsof -iTCP:$(SERVER_PORT) -sTCP:LISTEN -n -P; \
exit 1; \
fi
# Kill any lingering native server/client Python processes
native-clean:
@pgrep -f "src.nodes.client_node" | xargs -r kill -9 2>/dev/null || true
@pgrep -f "src.nodes.server_node" | xargs -r kill -9 2>/dev/null || true
@pgrep -f "run_experiment_matrix" | xargs -r kill -9 2>/dev/null || true
@sleep 1
@echo "Native processes stopped."
native-clean-results:
@rm -rf results/ bestweights/
@echo "results/ and bestweights/ deleted."
native-reset: native-clean native-clean-results
# ═══════════════════════════════════════════════════════════════════════════════
# DISTRIBUTED (dist-*)
# Real deployment: server on a remote VPS, clients on Raspberry Pis over
# a Tailscale overlay network.
# ═══════════════════════════════════════════════════════════════════════════════
# Sync config.yaml to VPS (scp) and all Pis (ansible).
# Run this whenever config.yaml changes — no need to rebuild the image.
dist-sync-config:
@echo "=== Syncing config ($(DEPLOY_CONFIG)), matrix.yaml and compose to VPS ==="
scp $(DEPLOY_CONFIG) $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/config.yaml
scp matrix.yaml $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/matrix.yaml
scp docker-compose.server.yml $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/docker-compose.server.yml
@echo "=== Syncing config ($(DEPLOY_CONFIG)) and matrix.yaml to all Pis ==="
ansible clients -i $(ANSIBLE_INV) \
-m copy \
-a "src=$(DEPLOY_CONFIG) dest=/home/pi/config.yaml" \
--become
ansible clients -i $(ANSIBLE_INV) \
-m copy \
-a "src=matrix.yaml dest=/home/pi/matrix.yaml" \
--become
# Restart only the server container on VPS using docker compose.
# Useful after a config change without touching Pi clients.
dist-server-restart:
@echo "=== Restarting server on VPS ==="
ssh $(VPS_USER)@$(VPS_HOST_DEPLOY) "\
cd ~/csc8114/code && \
docker compose -f docker-compose.server.yml down && \
SESSION_ID=$(SESSION_ID) SCENARIO_ID=$(SCENARIO_ID) docker compose -f docker-compose.server.yml up -d && \
echo '[VPS] Server restarted.'"
# Load image onto all Pis without Docker Hub access.
# Saves image from VPS → Mac → pushes to all Pis via Ansible.
# Pi → Docker Hub is often blocked; this works over Tailscale.
dist-load-image:
@echo "=== [1/3] Saving image from VPS to Mac ==="
ssh $(VPS_USER)@$(VPS_HOST_DEPLOY) "docker save $(CLIENT_IMAGE) | gzip > /tmp/fsl-client.tar.gz"
scp $(VPS_USER)@$(VPS_HOST_DEPLOY):/tmp/fsl-client.tar.gz /tmp/fsl-client.tar.gz
@echo "=== [2/3] Copying image to all Pis ==="
ansible clients -i $(ANSIBLE_INV) \
-m copy \
-a "src=/tmp/fsl-client.tar.gz dest=/tmp/fsl-client.tar.gz" \
--become
@echo "=== [3/3] Loading image on all Pis ==="
ansible clients -i $(ANSIBLE_INV) \
-m shell \
-a "docker load -i /tmp/fsl-client.tar.gz" \
--become
# Build arm64 image locally on Mac and push directly to all Pis.
# No Docker Hub or VPS needed — useful when Pi → internet is blocked.
dist-load-image-local:
@echo "=== [1/3] Building arm64 image on Mac ==="
docker buildx build --platform linux/arm64 -t fsl-client:arm64 --load -f Dockerfile .
docker save fsl-client:arm64 | gzip > /tmp/fsl-client.tar.gz
@echo "=== [2/3] Copying image to all Pis ==="
ansible clients -i $(ANSIBLE_INV) \
-m copy \
-a "src=/tmp/fsl-client.tar.gz dest=/tmp/fsl-client.tar.gz" \
--become
@echo "=== [3/3] Loading image on all Pis ==="
ansible clients -i $(ANSIBLE_INV) \
-m shell \
-a "docker load -i /tmp/fsl-client.tar.gz && docker tag fsl-client:arm64 cindyncl26/fsl-client:latest" \
--become
# Delete results/ and bestweights/ on all Pis, then recreate empty dirs.
dist-clean-results:
ansible clients -i $(ANSIBLE_INV) \
-m shell \
-a "rm -rf /home/pi/results /home/pi/bestweights && mkdir -p /home/pi/results /home/pi/bestweights" \
--become
# Delete results/ and bestweights/ on the VPS server, then recreate empty dirs.
dist-clean-server:
ssh $(VPS_USER)@$(VPS_HOST_DEPLOY) "\
sudo rm -rf ~/csc8114/code/results ~/csc8114/code/bestweights && \
mkdir -p ~/csc8114/code/results ~/csc8114/code/bestweights && \
echo '[VPS] results/ and bestweights/ cleared.'"
# Build a multi-architecture image (amd64 for VPS + arm64 for Pi) and push it
# to Docker Hub. Requires `docker buildx` with a multi-platform builder set up.
# Override IMAGE_TAG to version-pin the build, e.g. make dist-build IMAGE_TAG=sha-abc1234
dist-build:
docker buildx build \
--no-cache \
--platform linux/amd64,linux/arm64 \
-f Dockerfile \
-t $(REGISTRY)/fsl-client:$(IMAGE_TAG) \
--push .
# Deploy the specified image to all 11 Pis via Ansible over Tailscale.
# Uses $(ANSIBLE_INV)
dist-deploy:
ansible-playbook ansible/deploy_client.yml \
-i $(ANSIBLE_INV) \
--extra-vars "image_tag=$(IMAGE_TAG)"
dist-deploy-cindy:
ansible-playbook ansible/deploy_client.yml \
-i $(ANSIBLE_INV) \
--extra-vars "image_tag=$(IMAGE_TAG) session_id=$(SESSION_ID) scenario_id=$(SCENARIO_ID)"
# Dry-run: show what Ansible would change without touching any Pi
dist-deploy-check:
ansible-playbook ansible/deploy_client.yml \
-i $(ANSIBLE_INV) \
--extra-vars "image_tag=$(IMAGE_TAG)" \
--check
# Full experiment launch in four steps:
# 1. Sync config.yaml to VPS + all Pis
# 2. SSH into VPS → restart server via docker compose
# 3. Poll VPS_HOST_DEPLOY:SERVER_PORT until gRPC port is open (or timeout)
# 4. Ansible: restart client containers on all Pis
#
# Usage:
# make dist-start # uses IMAGE_TAG=latest
# make dist-start IMAGE_TAG=sha-a1b2c3 # pins a specific build
dist-start:
@echo "=== [1/4] Syncing config.yaml to VPS + Pis ==="
$(MAKE) dist-sync-config
@echo "=== [2/4] Restarting server on VPS ==="
SESSION_ID=$(SESSION_ID) SCENARIO_ID=$(SCENARIO_ID) $(MAKE) dist-server-restart
@echo "[SKIP] Skipping reachability check, deploying clients directly..."
@echo "=== [4/4] Deploying clients to Pis (image=$(IMAGE_TAG)) ==="
ansible-playbook ansible/deploy_client.yml \
-i $(ANSIBLE_INV) \
--extra-vars "image_tag=$(IMAGE_TAG) session_id=$(SESSION_ID) scenario_id=$(SCENARIO_ID)"
@echo ""
@echo "Experiment is running. Follow logs with: make dist-logs"
# Nuclear restart: stop everything, wipe all results, then do a full fresh start.
# Equivalent to: stop server + stop all Pi clients + clean VPS + clean Pis + dist-start
dist-restart:
@echo "=== [1/5] Stopping server on VPS ==="
ssh $(VPS_USER)@$(VPS_HOST_DEPLOY) "\
cd ~/csc8114/code && \
docker compose -f docker-compose.server.yml down 2>/dev/null || true && \
echo '[VPS] Server stopped.'"
@echo "=== [2/5] Stopping client containers on all Pis ==="
ansible clients -i $(ANSIBLE_INV) \
-m shell \
-a "docker stop fsl-client 2>/dev/null || true && docker rm fsl-client 2>/dev/null || true" \
--become
@echo "=== [3/5] Clearing results on VPS ==="
$(MAKE) dist-clean-server
@echo "=== [4/5] Clearing results on all Pis ==="
$(MAKE) dist-clean-results
@echo "=== [5/5] Fresh start ==="
$(MAKE) dist-start
# Stream live server logs from the VPS; Ctrl-C to stop
dist-logs:
ssh $(VPS_USER)@$(VPS_HOST_DEPLOY) "docker logs -f fsl-server"
dist-fetch:
@echo "Fetching latest experiment results from VPS..."
@rsync -azP $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/results/ ./results/
@rsync -azP $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/bestweights/ ./bestweights/
@echo "Results and Weights synchronized to local machine."
dist-fetch-server:
@echo "=== Fetching results and bestweights from VPS (with periodic) ==="
@rsync -azP $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/results/logs/ ./results/logs/server/
@rsync -azP --exclude='logs/' $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/results/ ./results/
@rsync -azP $(VPS_USER)@$(VPS_HOST_DEPLOY):~/csc8114/code/bestweights/ ./bestweights/
@echo "✓ VPS results and bestweights synchronized."
dist-fetch-clients:
@echo "=== Fetching results and bestweights from all Pis (with periodic) ==="
@ansible clients -i $(ANSIBLE_INV) -m synchronize \
-a "src=/home/pi/results/ dest=$(PWD)/results/ mode=pull rsync_opts=--exclude=logs/" \
--become
@ansible clients -i $(ANSIBLE_INV) -m synchronize \
-a "src=/home/pi/results/logs/ dest=$(PWD)/results/logs/{{ inventory_hostname }}/ mode=pull" \
--become
@ansible clients -i $(ANSIBLE_INV) -m synchronize \
-a "src=/home/pi/bestweights/ dest=$(PWD)/bestweights/ mode=pull" \
--become
@echo "✓ All Pi results and bestweights synchronized."
dist-fetch-all: dist-fetch-server dist-fetch-clients
@echo "✓ All server and client results synchronized."
# ═══════════════════════════════════════════════════════════════════════════════
# EVALUATION (eval-*)
# ═══════════════════════════════════════════════════════════════════════════════
# Evaluate the most recently saved checkpoint against the held-out test set
eval-latest:
@SESSION=$$(ls -1dt bestweights/20* 2>/dev/null | head -n1 | xargs -I{} basename {}); \
if [ -z "$$SESSION" ]; then \
echo "No session found under bestweights/"; exit 1; \
fi; \
echo "Evaluating session $$SESSION..."; \
$(PYTHON) -m src.data.run_evaluation --device $(PLOT_DEVICE) --session $$SESSION
eval-session:
@if [ -z "$(SESSION)" ]; then \
echo "Usage: make eval-session SESSION=2026-03-13_03-19-17 [PLOT_DEVICE=cpu|mps]"; exit 1; \
fi
$(PYTHON) -m src.data.run_evaluation --device $(PLOT_DEVICE) --session $(SESSION)
# Batch evaluation over multiple sessions; supports optional filters
eval-batch:
@CMD="$(PYTHON) -m src.data.batch_run_evaluation \
--sessions-root $(if $(SESSIONS_ROOT),$(SESSIONS_ROOT),bestweights) \
--device $(PLOT_DEVICE)"; \
if [ -n "$(ONLY)" ]; then CMD="$$CMD --only $(ONLY)"; fi; \
if [ -n "$(LIMIT)" ]; then CMD="$$CMD --limit $(LIMIT)"; fi; \
if [ -n "$(FORCE_THRESHOLD)" ]; then CMD="$$CMD --force-prob-threshold $(FORCE_THRESHOLD)"; fi; \
if [ -n "$(REPORT_TAG)" ]; then CMD="$$CMD --report-tag $(REPORT_TAG)"; fi; \
if [ "$(CONTINUE_ON_ERROR)" = "1" ]; then CMD="$$CMD --continue-on-error"; fi; \
if [ "$(DRY_RUN)" = "1" ]; then CMD="$$CMD --dry-run"; fi; \
echo "Running: $$CMD"; eval "$$CMD"
# ═══════════════════════════════════════════════════════════════════════════════
# PLOTS (plot-*)
# ═══════════════════════════════════════════════════════════════════════════════
plot-latest:
@SESSION=$$(ls -1dt results/20* 2>/dev/null | head -n1 | xargs -I{} basename {}); \
if [ -z "$$SESSION" ]; then \
echo "No session found under results/"; exit 1; \
fi; \
echo "Plotting session $$SESSION..."; \
$(PYTHON) -m src.data.plot_training_curve --session $$SESSION --device $(PLOT_DEVICE); \
$(PYTHON) -m src.data.plot_server_metrics --log results/$$SESSION/server_log_$$SESSION.csv; \
$(PYTHON) -m src.data.plot_confusion_matrix --session $$SESSION --phase both
plot-session:
@if [ -z "$(SESSION)" ]; then \
echo "Usage: make plot-session SESSION=2026-03-13_01-53-07 [PLOT_DEVICE=cpu|mps]"; exit 1; \
fi
$(PYTHON) -m src.data.plot_training_curve --session $(SESSION) --device $(PLOT_DEVICE)
$(PYTHON) -m src.data.plot_server_metrics --log results/$(SESSION)/server_log_$(SESSION).csv
$(PYTHON) -m src.data.plot_confusion_matrix --session $(SESSION) --phase both
plot-confusion:
@if [ -z "$(SESSION)" ]; then \
echo "Usage: make plot-confusion SESSION=2026-03-13_01-53-07"; exit 1; \
fi
$(PYTHON) -m src.data.plot_confusion_matrix --session $(SESSION) --phase both
# ═══════════════════════════════════════════════════════════════════════════════
# EXPERIMENT MATRIX (matrix*)
# Runs the 14-scenario ablation suite defined in config.yaml.
# ═══════════════════════════════════════════════════════════════════════════════
matrix:
@CMD="$(PYTHON) -m src.data.run_experiment_matrix \
--config config.yaml \
--matrix-config $(if $(MATRIX_CONFIG),$(MATRIX_CONFIG),matrix.yaml)"; \
if [ -n "$(ONLY)" ]; then CMD="$$CMD --only $(ONLY)"; fi; \
if [ -n "$(BACKEND)" ]; then CMD="$$CMD --backend $(BACKEND)"; fi; \
if [ -n "$(MAX_RUNS)" ]; then CMD="$$CMD --max-runs $(MAX_RUNS)"; fi; \
echo "Running: $$CMD"; eval "$$CMD"
matrix-dry-run:
@CMD="$(PYTHON) -m src.data.run_experiment_matrix \
--config config.yaml \
--matrix-config $(if $(MATRIX_CONFIG),$(MATRIX_CONFIG),matrix.yaml) --dry-run"; \
if [ -n "$(ONLY)" ]; then CMD="$$CMD --only $(ONLY)"; fi; \
if [ -n "$(BACKEND)" ]; then CMD="$$CMD --backend $(BACKEND)"; fi; \
if [ -n "$(MAX_RUNS)" ]; then CMD="$$CMD --max-runs $(MAX_RUNS)"; fi; \
echo "Running: $$CMD"; eval "$$CMD"
matrix-report:
@if [ -z "$(SESSION)" ]; then \
echo "Usage: make matrix-report SESSION=2026-04-09_08-11-48"; exit 1; \
fi
$(PYTHON) src/data/generate_matrix_report.py --session $(SESSION)