/** * weather_edge_wrapper.cpp * ====================== * Edge runtime wrapper for the Weather RL model (.mnn). * * Design: * - External GRU hidden-state management (hidden_in → hidden_out per step). * - Action masking applied here, after logits are read from the model. * - Vulkan preferred (Mali-G31), CPU fallback if Vulkan session fails. * - Input tensors cached at init; not re-fetched on every step. * - C interface exposed for processing_nodes integration. * * Tensor names (match StatelessInferenceWrapper in mnn_export.py): * Inputs (in canonical sorted key order, then hidden_in): * "basin_context" [1, 4] flat: BASIN_FLAT (schema v3+) * "forecast_precip" [1, N_ZONES, HORIZON_DAYS] flat: N_ZONES * HORIZON_DAYS * "forecast_uncertainty" [1, N_ZONES] * "prior_belief" [1, 1] * "zone_belief" [1, N_ZONES] * "hidden_in" [1, 1, HIDDEN_SIZE] flat: HIDDEN_SIZE * Outputs: * "action_logits" [1, N_ACTIONS] * "hidden_out" [1, 1, HIDDEN_SIZE] flat: HIDDEN_SIZE * * Notes: * - action_mask is NOT in the MNN graph — applied externally here. * Terminate action (index N_ZONES) is never masked. * - export_keys in mnn_export.py are sorted(obs_keys) with action_mask * excluded, so the canonical input order is alphabetical: * basin_context, forecast_precip, forecast_uncertainty, * prior_belief, zone_belief * - basin_context (schema v3) is [enso_oni, iod_dmi, itcz_latitude_deg, * mslp_regional_hpa], matching zone_observation.BasinContext field * order. When no basin data is available at the edge, pass the neutral * default {0.0f, 0.0f, 0.0f, 1013.25f} (same default * weather_forecast_env.py uses for episodes without basin data). * BREAKING vs the pre-v3 interface: runStep()/weather_run() gained a * basin_context parameter, and the MNN graph gained the input — old * callers and old .mnn models must be updated together. * - forecast_precip is [N_ZONES, HORIZON_DAYS] — pass it row-major * (all days for zone 0, then all days for zone 1, etc.). * - forecast_uncertainty is [N_ZONES] — one scalar per zone, not per day. * - HIDDEN_SIZE default is 64 (gru_weather_policy.py default). * N_ZONES and HORIZON_DAYS vary by curriculum phase; they are set here * to the final curriculum phase (heatwave/humidity: 4 zones, 30 days). * Always confirm these match your deployed checkpoint. * - Backend order: Vulkan → CPU. * * Bug 2.5 fix: * weather_run() and weather_destroy() now guard against null handles. * weather_create() already returns nullptr on failure, but callers that * omit the null check and pass the result directly to weather_run() would * previously trigger undefined behaviour (segfault on dereference). * weather_run() now returns -1 immediately on a null handle. * weather_destroy() now no-ops on a null handle (delete nullptr is safe * in C++, but the explicit guard makes the contract visible to callers). */ #include #include #include #include #include #include #include #include #include // --------------------------------------------------------------------------- // Shape constants — must match the exported checkpoint's ForecastConfig // // Default ForecastConfig (zone_observation.py): // horizon_days = 30 // n_zones = 1 (single-zone baseline) // // Final curriculum phase (train_curriculum.py heatwave/humidity): // horizon_days = 30 (ForecastConfig default, unchanged by curriculum) // n_zones = 4 // // GRU policy default (gru_weather_policy.py): // hidden_size = 64 // // --------------------------------------------------------------------------- static constexpr int N_ZONES = 4; // zones in the deployed checkpoint static constexpr int HORIZON_DAYS = 30; // ForecastConfig.horizon_days default static constexpr int N_LAYERS = 1; // GRU num_layers (always 1) static constexpr int HIDDEN_SIZE = 64; // GRU hidden_size default static constexpr int N_ACTIONS = N_ZONES + 1; // zone actions + terminate // Derived sizes (flat float counts) static constexpr int PRECIP_FLAT = N_ZONES * HORIZON_DAYS; // forecast_precip flat size static constexpr int HIDDEN_FLAT = N_LAYERS * 1 * HIDDEN_SIZE; // batch=1 static constexpr int BASIN_FLAT = 4; // basin_context: [enso_oni, iod_dmi, itcz_lat, mslp_hpa] // Neutral basin default (matches weather_forecast_env._BASIN_CONTEXT_NEUTRAL). static constexpr float BASIN_NEUTRAL[BASIN_FLAT] = {0.0f, 0.0f, 0.0f, 1013.25f}; // --------------------------------------------------------------------------- // WeatherEdgeWrapper // --------------------------------------------------------------------------- class WeatherEdgeWrapper { public: explicit WeatherEdgeWrapper(const std::string& model_path) : model_path_(model_path) { net_ = std::shared_ptr( MNN::Interpreter::createFromFile(model_path.c_str()), MNN::Interpreter::destroy ); if (!net_) { std::cerr << "[WeatherWrapper] FATAL: failed to load model from " << model_path << std::endl; return; } // --- Try Vulkan first (Mali-G31), fall back to CPU --- session_ = tryCreateSession(MNN_FORWARD_VULKAN, "Vulkan"); if (!session_) { std::cerr << "[WeatherWrapper] Vulkan unavailable, falling back to CPU" << std::endl; session_ = tryCreateSession(MNN_FORWARD_CPU, "CPU"); } if (!session_) { std::cerr << "[WeatherWrapper] FATAL: could not create any MNN session" << std::endl; return; } // --- Resize all input tensors before resizeSession --- // mnn_export.py uses dynamic batch axes; providing explicit shapes // ensures MNN allocates memory correctly for batch=1 inference. // Input order matches sorted export_keys + hidden_in (alphabetical): // basin_context, forecast_precip, forecast_uncertainty, // prior_belief, zone_belief, hidden_in auto* t_basin = net_->getSessionInput(session_, "basin_context"); if (t_basin) net_->resizeTensor(t_basin, {1, BASIN_FLAT}); auto* t_precip = net_->getSessionInput(session_, "forecast_precip"); if (t_precip) net_->resizeTensor(t_precip, {1, N_ZONES, HORIZON_DAYS}); auto* t_unc = net_->getSessionInput(session_, "forecast_uncertainty"); if (t_unc) net_->resizeTensor(t_unc, {1, N_ZONES}); auto* t_prior = net_->getSessionInput(session_, "prior_belief"); if (t_prior) net_->resizeTensor(t_prior, {1, 1}); auto* t_belief = net_->getSessionInput(session_, "zone_belief"); if (t_belief) net_->resizeTensor(t_belief, {1, N_ZONES}); // hidden_in shape: [1, 1, HIDDEN_SIZE] (n_layers=1, batch=1, hidden_size) auto* t_hidden = net_->getSessionInput(session_, "hidden_in"); if (t_hidden) net_->resizeTensor(t_hidden, {1, 1, HIDDEN_SIZE}); net_->resizeSession(session_); // --- Cache input tensor pointers (avoid per-step lookup) --- in_forecast_precip_ = checkedGetInput("forecast_precip"); in_forecast_unc_ = checkedGetInput("forecast_uncertainty"); in_prior_belief_ = checkedGetInput("prior_belief"); in_zone_belief_ = checkedGetInput("zone_belief"); in_hidden_ = checkedGetInput("hidden_in"); // --- Cache output tensor pointers --- // Output names from StatelessInferenceWrapper._export_onnx: // output_names = ["action_logits", "hidden_out"] out_action_logits_ = checkedGetOutput("action_logits"); out_hidden_ = checkedGetOutput("hidden_out"); ready_ = in_basin_context_ && in_forecast_precip_ && in_forecast_unc_ && in_prior_belief_ && in_zone_belief_ && in_hidden_ && out_action_logits_ && out_hidden_; if (ready_) { std::cout << "[WeatherWrapper] Ready. Backend: " << (usingVulkan_ ? "Vulkan" : "CPU") << " Model: " << model_path << std::endl; } else { std::cerr << "[WeatherWrapper] WARNING: one or more tensor names not found. " << "Check tensor names against mnn_export.py." << std::endl; } } bool isReady() const { return ready_; } /** * Run one inference step. * * Inputs (flat float arrays, caller-owned): * forecast_precip [N_ZONES * HORIZON_DAYS] * Row-major: all HORIZON_DAYS for zone 0, then zone 1, etc. * Matches forecast_precip[n_zones, horizon_days] in Python. * forecast_uncertainty[N_ZONES] one uncertainty value per zone (NOT per day) * prior_belief [1] * zone_belief [N_ZONES] * hidden_in [N_LAYERS * 1 * HIDDEN_SIZE] zeros on episode start * action_mask [N_ACTIONS] 1.0f = valid, 0.0f = invalid * Terminate action (index N_ZONES) is always valid. * * Outputs (flat float arrays, caller-allocated): * logits_out [N_ACTIONS] masked: invalid actions set to -1e9 * hidden_out [N_LAYERS * 1 * HIDDEN_SIZE] store for next step * * Returns: chosen action index (argmax over masked logits), or -1 on error. */ int runStep( const float* basin_context, // length BASIN_FLAT (schema v3) const float* forecast_precip, // length N_ZONES * HORIZON_DAYS const float* forecast_uncertainty, // length N_ZONES const float* prior_belief, // length 1 const float* zone_belief, // length N_ZONES const float* hidden_in, // length HIDDEN_FLAT const float* action_mask, // length N_ACTIONS; 1=valid, 0=invalid float* logits_out, // length N_ACTIONS (output) float* hidden_out // length HIDDEN_FLAT (output) ) { if (!ready_) { std::cerr << "[WeatherWrapper] runStep called on unready wrapper" << std::endl; return -1; } // --- Fill input tensors via host-side wrappers --- // Alphabetical order matches mnn_export.py export_keys sort. copyIn(in_basin_context_, basin_context, BASIN_FLAT); copyIn(in_forecast_precip_, forecast_precip, PRECIP_FLAT); copyIn(in_forecast_unc_, forecast_uncertainty, N_ZONES); copyIn(in_prior_belief_, prior_belief, 1); copyIn(in_zone_belief_, zone_belief, N_ZONES); copyIn(in_hidden_, hidden_in, HIDDEN_FLAT); // --- Run --- if (net_->runSession(session_) != MNN::NO_ERROR) { std::cerr << "[WeatherWrapper] runSession failed" << std::endl; return -1; } // --- Read outputs --- copyOut(out_action_logits_, logits_out, N_ACTIONS); copyOut(out_hidden_, hidden_out, HIDDEN_FLAT); // --- Apply action mask --- // Terminate action (index N_ZONES) is ALWAYS valid regardless of mask. // All other actions: masked if action_mask[i] <= 0.5. // Protocol matches mnn_export.py EDGE_INFERENCE_NOTE: // logits[action_mask == 0] = -1e9; action = argmax(logits) int best_action = -1; float best_logit = -1e38f; for (int i = 0; i < N_ACTIONS; ++i) { bool valid = (i == N_ZONES) ? true : (action_mask[i] > 0.5f); if (!valid) { logits_out[i] = -1e9f; } else if (logits_out[i] > best_logit) { best_logit = logits_out[i]; best_action = i; } } return best_action; } private: // --- Helpers --- MNN::Session* tryCreateSession(MNNForwardType type, const char* label) { MNN::ScheduleConfig config; config.type = type; config.numThread = 2; MNN::BackendConfig backendConfig; backendConfig.precision = MNN::BackendConfig::Precision_Low; // fp16 on GPU backendConfig.memory = MNN::BackendConfig::Memory_Low; config.backendConfig = &backendConfig; auto* s = net_->createSession(config); if (s) { std::cout << "[WeatherWrapper] Session created on " << label << std::endl; if (type == MNN_FORWARD_VULKAN) usingVulkan_ = true; } return s; } MNN::Tensor* checkedGetInput(const char* name) { auto* t = net_->getSessionInput(session_, name); if (!t) std::cerr << "[WeatherWrapper] WARNING: input tensor not found: " << name << std::endl; return t; } MNN::Tensor* checkedGetOutput(const char* name) { auto* t = net_->getSessionOutput(session_, name); if (!t) std::cerr << "[WeatherWrapper] WARNING: output tensor not found: " << name << std::endl; return t; } // Copy host float array → MNN tensor via a temporary host-layout wrapper. static void copyIn(MNN::Tensor* dst, const float* src, int n) { MNN::Tensor host(dst, MNN::Tensor::TENSORFLOW); std::memcpy(host.host(), src, n * sizeof(float)); dst->copyFromHostTensor(&host); } // Copy MNN tensor → host float array via a temporary host-layout wrapper. static void copyOut(MNN::Tensor* src, float* dst, int n) { MNN::Tensor host(src, MNN::Tensor::TENSORFLOW); src->copyToHostTensor(&host); std::memcpy(dst, host.host(), n * sizeof(float)); } // --- Members --- std::string model_path_; std::shared_ptr net_; MNN::Session* session_ = nullptr; bool ready_ = false; bool usingVulkan_ = false; // Cached input tensors (alphabetical — matches export_keys sort order) MNN::Tensor* in_basin_context_ = nullptr; // schema v3 MNN::Tensor* in_forecast_precip_ = nullptr; MNN::Tensor* in_forecast_unc_ = nullptr; MNN::Tensor* in_prior_belief_ = nullptr; MNN::Tensor* in_zone_belief_ = nullptr; MNN::Tensor* in_hidden_ = nullptr; // Cached output tensors MNN::Tensor* out_action_logits_ = nullptr; MNN::Tensor* out_hidden_ = nullptr; }; // --------------------------------------------------------------------------- // C interface — for processing_nodes integration // --------------------------------------------------------------------------- extern "C" { /** * Create a wrapper instance. * Returns opaque handle, or nullptr on failure. */ void* weather_create(const char* model_path) { auto* w = new WeatherEdgeWrapper(model_path); if (!w->isReady()) { delete w; return nullptr; } return w; } /** * Run one inference step. * * Bug 2.5 fix: null handle guard added. weather_create() returns nullptr * on failure; callers that omit the null check would previously trigger * undefined behaviour (segfault) here. Now returns -1 immediately. * * basin_context: float[BASIN_FLAT] [enso_oni, iod_dmi, * itcz_latitude_deg, mslp_regional_hpa] (schema v3). * Pass BASIN_NEUTRAL {0,0,0,1013.25} when unknown. * forecast_precip: float[N_ZONES * HORIZON_DAYS], row-major * (all days for zone 0, then zone 1, etc.) * forecast_uncertainty:float[N_ZONES] one value per zone * prior_belief: float[1] * zone_belief: float[N_ZONES] * hidden_in: float[HIDDEN_SIZE] zeros at episode start * action_mask: float[N_ACTIONS] 1.0=valid, 0.0=masked * Terminate (index N_ZONES) always valid. * logits_out: float[N_ACTIONS] masked logits (out) * hidden_out: float[HIDDEN_SIZE] new hidden state (out) * * Returns chosen action index (0 .. N_ACTIONS-1), or -1 on error. */ int weather_run( void* handle, const float* basin_context, // float[BASIN_FLAT] (schema v3) const float* forecast_precip, // float[N_ZONES * HORIZON_DAYS] const float* forecast_uncertainty, // float[N_ZONES] const float* prior_belief, // float[1] const float* zone_belief, // float[N_ZONES] const float* hidden_in, // float[HIDDEN_SIZE] const float* action_mask, // float[N_ACTIONS] float* logits_out, // float[N_ACTIONS] (out) float* hidden_out // float[HIDDEN_SIZE] (out) ) { // Bug 2.5 fix: guard against null handle (weather_create failed) if (!handle) { std::cerr << "[WeatherWrapper] weather_run called with null handle" << std::endl; return -1; } auto* w = static_cast(handle); return w->runStep(basin_context, forecast_precip, forecast_uncertainty, prior_belief, zone_belief, hidden_in, action_mask, logits_out, hidden_out); } /** * Destroy a wrapper instance. * * Bug 2.5 fix: null handle guard added for symmetry with weather_run. * delete nullptr is safe in C++ but the explicit check makes the * contract visible and prevents a confusing double-free if a caller * passes nullptr after a failed weather_create(). */ void weather_destroy(void* handle) { if (!handle) return; delete static_cast(handle); } /** Query shape constants — lets calling code stay in sync without hardcoding. */ int weather_n_zones() { return N_ZONES; } int weather_n_actions() { return N_ACTIONS; } int weather_horizon_days() { return HORIZON_DAYS; } int weather_hidden_size() { return HIDDEN_SIZE; } int weather_precip_flat() { return PRECIP_FLAT; } // N_ZONES * HORIZON_DAYS int weather_basin_flat() { return BASIN_FLAT; } // 4 (schema v3) } // extern "C"