Reinforcement Learning
stable-baselines3
deep-reinforcement-learning
agricultural-ai
weather-modelling
curriculum-learning
edge-ai
Instructions to use DHDRL/monsoon-rl with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- stable-baselines3
How to use DHDRL/monsoon-rl with stable-baselines3:
from huggingface_sb3 import load_from_hub checkpoint = load_from_hub( repo_id="DHDRL/monsoon-rl", filename="{MODEL FILENAME}.zip", ) - Notebooks
- Google Colab
- Kaggle
File size: 29,178 Bytes
976eb45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 | """
mnn_export.py
=============
"""
from __future__ import annotations
import argparse
import hashlib
import logging
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import zone_observation as _zo
assert _zo.SCHEMA_VERSION == 3, (
f"mnn_export: zone_observation schema mismatch "
f"(expected 3, got {_zo.SCHEMA_VERSION})"
)
from zone_observation import ForecastConfig
try:
from weather_forecast_env import make_weather_env
from gru_weather_policy import GRUWeatherFeaturesExtractor, create_gru_weather_policy_kwargs
_ML_AVAILABLE = True
except ImportError:
_ML_AVAILABLE = False
GRUWeatherFeaturesExtractor = None # type: ignore[assignment,misc]
create_gru_weather_policy_kwargs = None # type: ignore[assignment]
make_weather_env = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Hardware capability reporter
# ---------------------------------------------------------------------------
def report_edge_capability() -> str:
"""
Detect GPU/compute backend on the current device.
Returns one of: 'VULKAN', 'OPENCL', 'CPU_ONLY'.
"""
import subprocess
try:
out = subprocess.run(
["vulkaninfo", "--summary"],
capture_output=True, text=True, timeout=5,
).stdout
if "Vulkan" in out:
logger.info("Edge capability: Vulkan detected -> MNN Vulkan backend")
return "VULKAN"
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
try:
out = subprocess.run(
["clinfo"], capture_output=True, text=True, timeout=5,
).stdout
if "Mali" in out:
if "OpenCL 2" in out or "OpenCL 3" in out:
logger.info("Edge capability: Mali + OpenCL 2/3 -> MNN OpenCL backend")
return "OPENCL"
if "Mali-450" in out or "Utgard" in out:
logger.warning(
"Edge capability: Mali-450 (Utgard) detected. "
"No OpenCL / Vulkan support. Forcing CPU fallback. "
"The quantized .mnn file will still run β just slower."
)
return "CPU_ONLY"
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
logger.info("Edge capability: GPU info unavailable -> defaulting to CPU_ONLY")
return "CPU_ONLY"
# ---------------------------------------------------------------------------
# StatelessInferenceWrapper β explicit hidden state I/O for GRU tracing
# ---------------------------------------------------------------------------
class StatelessInferenceWrapper:
"""
Wraps the PPO actor so hidden state is explicit I/O rather than Python state.
WHY THIS EXISTS:
torch.jit.trace records a single execution path. GRUWeatherFeaturesExtractor
keeps _hidden as a Python object (None on first call, Tensor thereafter).
This branching logic is invisible to the tracer β the resulting graph would
always reinitialise hidden state, silently breaking temporal belief
propagation on edge.
This wrapper eliminates the branch: hidden_state is accepted as an explicit
input tensor and returned as an explicit output. The edge runtime manages
hidden state externally between steps.
INPUTS (fixed float32):
obs_tensors: list of observation tensors in canonical key order
hidden_in: [1, 1, hidden_size] float32 GRU hidden state
OUTPUTS:
action_logits: [1, n_actions] float32 β apply mask + argmax on edge
hidden_out: [1, 1, hidden_size] float32 β feed back next step
The interface is identical to the previous DQN export from the edge's
perspective: a vector of per-action scores, a mask, and persistent hidden
state. No changes needed to edge runtime code.
"""
def __init__(
self,
features_extractor,
mlp_extractor,
action_net,
hidden_size: int,
obs_keys: List[str],
):
self.features_extractor = features_extractor
self.mlp_extractor = mlp_extractor
self.action_net = action_net
self.hidden_size = hidden_size
self.obs_keys = obs_keys
def forward(
self,
obs_tensors: List, # one tensor per obs_key, in canonical order
hidden_in, # [1, 1, hidden_size]
) -> Tuple:
"""Pure function β no Python-object state. Safe to trace."""
# Rebuild obs dict from positional tensors (tracing-safe)
obs = {k: obs_tensors[i] for i, k in enumerate(self.obs_keys)}
# Inject external hidden state into the features extractor
self.features_extractor.set_hidden(hidden_in)
# Extract features (MLP/conv + GRU step)
features = self.features_extractor(obs)
# Actor path only β discard value/critic at export time
latent_pi = self.mlp_extractor.forward_actor(features)
# Action logits [1, n_actions]
action_logits = self.action_net(latent_pi)
# Return updated hidden state for the edge runtime to store
hidden_out = self.features_extractor.get_hidden()
return action_logits, hidden_out
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
def _validate_output_path(path_str: str) -> Path:
p = Path(path_str).resolve()
if p.suffix != ".mnn":
raise ValueError(
f"Output path must end with .mnn, got: {path_str!r}"
)
p.parent.mkdir(parents=True, exist_ok=True)
return p
def _sha256_file(path: Path, chunk_size: int = 1 << 20) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(chunk_size):
h.update(chunk)
return h.hexdigest()
# ---------------------------------------------------------------------------
# Calibration data collection
# ---------------------------------------------------------------------------
def _collect_calibration_obs(
env,
n_episodes: int = 100,
obs_keys: Optional[List[str]] = None,
) -> List[Dict]:
"""
Run random episodes to collect representative observations for PTQ calibration.
100β200 episodes is sufficient for most RL policies.
"""
import numpy as np
logger.info("Collecting calibration data (%d episodes)...", n_episodes)
samples = []
for ep in range(n_episodes):
obs, _ = env.reset()
done = False
steps = 0
while not done and steps < 50:
sample = {k: v for k, v in obs.items() if k != "action_mask"}
samples.append(sample)
action = env.action_space.sample()
obs, _, terminated, truncated, _ = env.step(action)
done = terminated or truncated
steps += 1
if (ep + 1) % 20 == 0:
logger.info(" Calibration: %d/%d episodes", ep + 1, n_episodes)
logger.info("Collected %d calibration samples", len(samples))
return samples
# ---------------------------------------------------------------------------
# ONNX export
# ---------------------------------------------------------------------------
def _export_onnx(
wrapper: StatelessInferenceWrapper,
dummy_obs: Dict,
hidden_size: int,
onnx_path: Path,
export_keys: List[str],
) -> None:
"""
Export the stateless actor wrapper to ONNX.
action_mask is excluded from the ONNX graph β it is applied by the
edge runtime after receiving action logits (mask β argmax protocol).
hidden_state is explicit I/O for temporal belief propagation.
"""
import torch
obs_tensors = [dummy_obs[k].float() for k in export_keys]
dummy_hidden = torch.zeros(1, 1, hidden_size)
input_names = export_keys + ["hidden_in"]
output_names = ["action_logits", "hidden_out"]
dynamic_axes: Dict[str, Dict[int, str]] = {k: {0: "batch"} for k in export_keys}
dynamic_axes["hidden_in"] = {1: "batch"}
dynamic_axes["action_logits"] = {0: "batch"}
dynamic_axes["hidden_out"] = {1: "batch"}
logger.info("Exporting to ONNX (opset 17): %s", onnx_path)
logger.info(" Observation inputs: %s", export_keys)
logger.info(" Outputs: %s", output_names)
with torch.no_grad():
torch.onnx.export(
wrapper,
args=(obs_tensors, dummy_hidden),
f=str(onnx_path),
opset_version=17,
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
)
logger.info(
"ONNX saved: %s (%.1f MB)",
onnx_path, onnx_path.stat().st_size / 1e6,
)
# ---------------------------------------------------------------------------
# MNN conversion
# ---------------------------------------------------------------------------
def _convert_to_mnn(
onnx_path: Path,
mnn_path: Path,
quantize: str,
calibration_samples: Optional[List[Dict]] = None,
) -> None:
"""
Convert ONNX to MNN using the MNN Python API, with CLI fallback.
quantize options:
'int8' β weight-only INT8 (no calibration needed, recommended)
'fp16' β FP16 half-precision (higher accuracy, ~2x size reduction)
'none' β FP32 (largest, highest accuracy, use for debugging)
"""
logger.info(
"Converting to MNN quantize=%s target=%s", quantize, mnn_path
)
converted = False
try:
from MNN.tools import mnnconvert as _mnnconvert
args = {
"modelFile": str(onnx_path),
"MNNModel": str(mnn_path),
"framework": "ONNX",
"bizCode": "weather_rl_v1",
}
if quantize == "int8":
args["weightQuantBits"] = 8
elif quantize == "fp16":
args["fp16"] = True
_mnnconvert.convert(args)
converted = True
logger.info("MNN conversion via Python API: OK")
except Exception as api_err:
logger.warning("MNN Python API failed (%s) β trying CLI fallback", api_err)
if not converted:
import subprocess, shutil
cli = shutil.which("mnnconvert")
if cli is None:
raise RuntimeError(
"mnnconvert not found on PATH and MNN Python API failed.\n"
"Build from: https://github.com/GeniusVentures/MNN\n"
"Or install: pip install MNN"
)
cmd = [cli, "-f", "ONNX",
"--modelFile", str(onnx_path),
"--MNNModel", str(mnn_path)]
if quantize == "int8":
cmd += ["--weightQuantBits", "8"]
elif quantize == "fp16":
cmd += ["--fp16"]
logger.info("MNN CLI: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"mnnconvert CLI failed (rc={result.returncode}):\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
if not mnn_path.exists():
raise RuntimeError(
f"MNN conversion reported success but {mnn_path} was not created."
)
# ---------------------------------------------------------------------------
# Main export function
# ---------------------------------------------------------------------------
def export_to_mnn(
checkpoint_path: str,
output_mnn: str = "weather_rl_model.mnn",
quantize: str = "int8",
calibration_episodes: int = 0,
hidden_size: int = 64,
n_zones: int = 4,
keep_onnx: bool = False,
) -> Path:
"""
Export a trained MaskablePPO checkpoint to a quantized .mnn for edge deployment.
Args:
checkpoint_path: Path to the trained .zip checkpoint.
output_mnn: Output .mnn file path (must end with .mnn).
quantize: 'int8' (default), 'fp16', or 'none'.
calibration_episodes: Episodes for PTQ calibration (0 = weight-only).
hidden_size: GRU hidden size used during training (default 64).
n_zones: Number of zones the checkpoint was trained with.
Must match the curriculum phase: normal=2,
monsoon/drought=3, heatwave/humidity=4 (default 4).
ForecastConfig() defaults to n_zones=1, which is
wrong for any multi-zone checkpoint β always pass
the value that matches the training phase explicitly.
keep_onnx: If True, keep the intermediate .onnx file.
Returns:
Path to the created .mnn file.
"""
import torch
from sb3_contrib import MaskablePPO
ckpt = Path(checkpoint_path)
if not ckpt.exists():
raise FileNotFoundError(f"Checkpoint not found: {ckpt}")
if quantize not in ("int8", "fp16", "none"):
raise ValueError(f"quantize must be 'int8', 'fp16', or 'none', got {quantize!r}")
mnn_path = _validate_output_path(output_mnn)
onnx_path = mnn_path.with_suffix(".onnx")
backend = report_edge_capability()
# --- Load MaskablePPO checkpoint ---
logger.info("Loading checkpoint: %s", ckpt)
# FIX: use the n_zones that matches the training phase, not ForecastConfig()
# default of n_zones=1. The dummy environment produced by env.reset() is
# used only to build dummy_obs for ONNX tracing; its tensor shapes must
# match those of the loaded policy or the traced graph will have wrong
# input shapes and be incompatible with edge_wrapper.cpp (N_ZONES=4).
if n_zones < 1:
raise ValueError(f"n_zones must be >= 1, got {n_zones}")
config = ForecastConfig(n_zones=n_zones, horizon_days=30)
env = make_weather_env(config)
logger.info("Export env: n_zones=%d horizon_days=30", n_zones)
custom_objects = {}
if GRUWeatherFeaturesExtractor is not None:
# FIX (schema v3 / train_kaggle compat): pass ONLY the class, never a
# hardcoded features_extractor_kwargs. SB3 restores the extractor's
# kwargs from the checkpoint's saved policy_kwargs; anything passed
# here OVERRIDES them. The previous version forced
# spatial_output_size=8 and (by omission) basin_context_hidden=8,
# which mismatches train_kaggle.py checkpoints (trained with
# spatial_output_size=12, basin_context_hidden=12) and made
# MaskablePPO.load fail with a state_dict size mismatch -- i.e. the
# export path could not load the project's own training output.
custom_objects = {
"features_extractor_class": GRUWeatherFeaturesExtractor,
}
model = MaskablePPO.load(
str(ckpt),
env=env,
device="cpu",
custom_objects=custom_objects if custom_objects else None,
)
model.policy.eval()
# Verify the loaded policy has the expected actor components.
# This catches mismatches between the checkpoint and the export path
# (e.g. a checkpoint saved with a custom policy that removed mlp_extractor).
policy = model.policy
assert hasattr(policy, "mlp_extractor") and hasattr(policy, "action_net"), (
f"Loaded policy is missing expected actor components. "
f"Got attributes: {[a for a in dir(policy) if not a.startswith('_')]}"
)
assert hasattr(policy, "features_extractor"), (
"Loaded policy is missing features_extractor."
)
logger.info("Checkpoint loaded: %s", ckpt.name)
# Trust the checkpoint over the CLI flag for the GRU hidden size: the
# wrapper's hidden_in/hidden_out tensor shape must equal the trained
# GRU's, and a stale --hidden-size default would silently trace a
# wrong-shaped graph.
_ckpt_hidden = getattr(policy.features_extractor, "hidden_size", None)
if _ckpt_hidden is not None and _ckpt_hidden != hidden_size:
logger.warning(
"Overriding --hidden-size=%d with checkpoint's hidden_size=%d",
hidden_size, _ckpt_hidden,
)
hidden_size = _ckpt_hidden
# --- Build stateless actor wrapper ---
# We export the actor path only:
# features_extractor β mlp_extractor.forward_actor β action_net
# The critic (value_net) is discarded β not needed at inference time.
features_extractor = policy.features_extractor
mlp_extractor = policy.mlp_extractor
action_net = policy.action_net
obs_sample, _ = env.reset()
obs_keys_all = sorted(obs_sample.keys())
export_keys = [k for k in obs_keys_all if k != "action_mask"]
wrapper = StatelessInferenceWrapper(
features_extractor=features_extractor,
mlp_extractor=mlp_extractor,
action_net=action_net,
hidden_size=hidden_size,
obs_keys=export_keys,
)
# --- Build dummy input ---
dummy_obs: Dict[str, "torch.Tensor"] = {}
for k, v in obs_sample.items():
t = torch.from_numpy(v).unsqueeze(0)
dummy_obs[k] = t.float() if k != "action_mask" else t
# --- Optional calibration ---
calibration_samples = None
if calibration_episodes > 0 and quantize == "int8":
calibration_samples = _collect_calibration_obs(
env, n_episodes=calibration_episodes
)
# --- ONNX export ---
_export_onnx(wrapper, dummy_obs, hidden_size, onnx_path, export_keys)
# --- MNN conversion ---
try:
_convert_to_mnn(onnx_path, mnn_path, quantize, calibration_samples)
finally:
if onnx_path.exists() and not keep_onnx:
onnx_path.unlink()
logger.info("Removed intermediate ONNX: %s", onnx_path.name)
if not mnn_path.exists():
raise RuntimeError(
f"Export appeared to succeed but {mnn_path} was not created."
)
size_mb = mnn_path.stat().st_size / (1024 * 1024)
sha = _sha256_file(mnn_path)
logger.info(
"MNN export complete: %s (%.1f MB) SHA256: %s", mnn_path, size_mb, sha
)
logger.info("Edge backend detected: %s", backend)
print(f"""
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Weather RL Model β Edge Deployment Manifest β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β Model: {mnn_path.name:<44} β
β Size: {f'{size_mb:.1f} MB':<44} β
β Quantize: {quantize:<44} β
β Backend: {backend:<44} β
β SHA256: {sha[:44]} β
β {sha[44:]} β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β POST-PROCESSING (apply in edge runtime): β
β logits = model.run(obs_without_mask, hidden_in) β
β logits[action_mask == 0] = -1e9 β
β action = argmax(logits) β
β Store hidden_out; pass as hidden_in next step β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
""")
return mnn_path
# ---------------------------------------------------------------------------
# Edge inference protocol (copy into edge runtime documentation)
# ---------------------------------------------------------------------------
EDGE_INFERENCE_NOTE = """
Edge Runtime Inference Protocol
================================
The exported .mnn model is a stateless actor network. The edge runtime
must manage two pieces of state externally:
1. GRU hidden state (temporal belief):
- Initialise: hidden = zeros([1, 1, {hidden_size}])
- Each step: action_logits, hidden = model.run(obs_inputs, hidden)
- Reset: hidden = zeros([1, 1, {hidden_size}]) at episode start
2. Action mask (zone validity):
- The model outputs raw action logits [1, n_zones + 1]
- Apply mask BEFORE argmax:
action_logits[action_mask == 0] = -1e9
action = argmax(action_logits)
- The terminate action (index n_zones) is ALWAYS valid; never mask it.
Input tensor order (must match ONNX input_names exactly):
{obs_keys_without_mask} (float32)
hidden_in (float32, shape [1, 1, H])
Output tensors:
action_logits float32 [1, n_zones + 1] raw scores; apply mask + argmax
hidden_out float32 [1, 1, H] store and feed back next step
Note (schema v3): the observation inputs now include basin_context
(float32, shape [1, 4] -- [enso_oni, iod_dmi, itcz_latitude, mslp_anomaly]
in that field order, matching zone_observation.BasinContext.to_array()).
It sorts FIRST in the alphabetical input order above. edge_wrapper.cpp
has been updated to match; any other edge runtime built against the
pre-v3 (4-input) interface MUST add this input -- the MNN session will
fail or produce garbage logits if basin_context is left unbound. When no
basin data is available at the edge, feed the neutral default
[0.0, 0.0, 0.0, 1013.25] (same default weather_forecast_env.py uses).
"""
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Export MaskablePPO weather policy to quantized .mnn",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--checkpoint", required=True,
help="Path to trained .zip checkpoint")
p.add_argument("--output", default="weather_rl_model.mnn",
help="Output .mnn path")
p.add_argument("--quantize", default="int8",
choices=["int8", "fp16", "none"])
p.add_argument("--calibration-episodes", type=int, default=0,
help="Episodes for PTQ calibration (0 = weight-only)")
p.add_argument("--hidden-size", type=int, default=64)
p.add_argument(
"--n-zones", type=int, default=4,
help=(
"Zones the checkpoint was trained with. "
"normal=2, monsoon/drought=3, heatwave/humidity=4 (default 4). "
"Must match the curriculum phase or the ONNX trace will have wrong "
"input shapes and be incompatible with edge_wrapper.cpp."
),
)
p.add_argument("--keep-onnx", action="store_true")
p.add_argument("--capability", action="store_true",
help="Report edge hardware capability and exit")
return p.parse_args()
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
args = _parse_args()
if args.capability:
print(f"Edge backend: {report_edge_capability()}")
sys.exit(0)
try:
export_to_mnn(
checkpoint_path=args.checkpoint,
output_mnn=args.output,
quantize=args.quantize,
calibration_episodes=args.calibration_episodes,
hidden_size=args.hidden_size,
n_zones=args.n_zones,
keep_onnx=args.keep_onnx,
)
sys.exit(0)
except FileNotFoundError as e:
logger.error("Checkpoint not found: %s", e)
sys.exit(2)
except ValueError as e:
logger.error("Invalid argument: %s", e)
sys.exit(2)
except RuntimeError as e:
logger.error("Export failed: %s", e)
sys.exit(1)
except Exception as e:
logger.exception("Unexpected error: %s", e)
sys.exit(1)
# ---------------------------------------------------------------------------
# Self-test (no checkpoint or MNN required)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
if "--checkpoint" in sys.argv:
main()
else:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
print("=== mnn_export.py self-test (no checkpoint/MNN required) ===\n")
failures = []
def _assert(cond: bool, msg: str) -> None:
if not cond:
failures.append(msg)
print(f" FAIL: {msg}")
# 1. _validate_output_path rejects non-.mnn extensions
try:
_validate_output_path("/tmp/model.pkl")
_assert(False, "Should have rejected .pkl extension")
except ValueError:
pass
try:
p = _validate_output_path("/tmp/test_export.mnn")
_assert(p.suffix == ".mnn", "Resolved path should end in .mnn")
except Exception as e:
_assert(False, f"Valid .mnn path rejected: {e}")
print(" _validate_output_path OK")
# 2. StatelessInferenceWrapper can be constructed with mock components
try:
import torch
import torch.nn as nn
class _FakeExtractor:
def __call__(self, obs): return torch.zeros(1, 256)
def set_hidden(self, h): self._h = h
def get_hidden(self): return getattr(self, '_h', torch.zeros(1,1,64))
class _FakeMLPExtractor(nn.Module):
def forward_actor(self, x): return x[:, :128]
wrapper = StatelessInferenceWrapper(
features_extractor=_FakeExtractor(),
mlp_extractor=_FakeMLPExtractor(),
action_net=nn.Linear(128, 5),
hidden_size=64,
obs_keys=["basin_context", "forecast_precip",
"forecast_uncertainty", "prior_belief",
"zone_belief"],
)
_assert(wrapper.hidden_size == 64, "Wrong hidden_size on wrapper")
_assert(len(wrapper.obs_keys) == 5, "Wrong obs_keys count")
print(" StatelessInferenceWrapper construction OK")
except ImportError as e:
print(f" StatelessInferenceWrapper: torch not installed, skipped ({e})")
# 3. _sha256_file is deterministic
import tempfile
with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as f:
f.write(b"weather_rl_test" * 1000)
tmp = Path(f.name)
sha1 = _sha256_file(tmp)
sha2 = _sha256_file(tmp)
_assert(sha1 == sha2, "SHA256 not deterministic")
_assert(len(sha1) == 64, f"SHA256 wrong length: {len(sha1)}")
tmp.unlink()
print(f" _sha256_file OK sha={sha1[:16]}...")
# 4. report_edge_capability returns a known string
cap = report_edge_capability()
_assert(cap in ("VULKAN", "OPENCL", "CPU_ONLY"),
f"Unknown capability: {cap!r}")
print(f" report_edge_capability OK backend={cap}")
# 5. SCHEMA_VERSION guard
_assert(_zo.SCHEMA_VERSION == 3,
f"SCHEMA_VERSION guard not working (got {_zo.SCHEMA_VERSION})")
print(" SCHEMA_VERSION guard OK")
# 6. EDGE_INFERENCE_NOTE is complete
_assert(len(EDGE_INFERENCE_NOTE) > 100, "EDGE_INFERENCE_NOTE too short")
_assert("hidden_out" in EDGE_INFERENCE_NOTE, "missing hidden_out")
_assert("argmax" in EDGE_INFERENCE_NOTE, "missing argmax")
_assert("action_logits" in EDGE_INFERENCE_NOTE, "missing action_logits")
print(" EDGE_INFERENCE_NOTE present and complete")
# 7. export_keys excludes action_mask (schema v3 key set incl. basin_context)
sample_keys = ["action_mask", "basin_context", "forecast_precip",
"forecast_uncertainty", "prior_belief", "zone_belief"]
export = [k for k in sorted(sample_keys) if k != "action_mask"]
_assert("action_mask" not in export, "action_mask leaked into export_keys")
_assert(len(export) == 5, f"Expected 5 export keys, got {len(export)}")
_assert(export[0] == "basin_context",
"basin_context should sort first (alphabetical)")
print(" export_keys exclusion of action_mask OK")
print()
if failures:
print(f"FAILED {len(failures)} test(s):")
for f in failures:
print(f" - {f}")
sys.exit(1)
else:
print("All mnn_export self-tests passed.")
print()
print("To run the real export:")
print(" python mnn_export.py --checkpoint final_normal.zip")
print(" python mnn_export.py --capability")
|