Repository: NVIDIA/NVFlare
Pull request #3278: JSON Config for Edge Emulator
Author: login: nvidianz
Created: 2025-03-05T02:32:52Z | Merged: 2025-03-08T00:26:50Z

### Description
Added JSON configuration file for emulator. All the parameters, endpoint, num_clients and processor are defined in the JSON file.

### Types of changes
<!--- Put an `x` in all the boxes that apply, and remove the not applicable items -->
- [x] Non-breaking change (fix or new feature that would not break existing functionality).
- [ ] Breaking change (fix or new feature that would cause existing functionality to change).
- [ ] New tests added to cover the changes.
- [ ] Quick tests passed locally by running `./runtest.sh`.
- [ ] In-line docstrings updated.
- [ ] Documentation updated.



Diff:
diff --git a/nvflare/edge/aggregators/edge_json_accumulator.py b/nvflare/edge/aggregators/edge_json_accumulator.py
index 94d773056c..2c2894bb59 100644
--- a/nvflare/edge/aggregators/edge_json_accumulator.py
+++ b/nvflare/edge/aggregators/edge_json_accumulator.py
@@ -45,7 +45,7 @@ def accept(self, shareable: Shareable, fl_ctx: FLContext) -> bool:
 
         # bottom level does not have num_devices
         # in which case num_devices_to_add is 1
-        num_devices_to_add = shareable.get("num_devices")
+        num_devices_to_add = shareable.get(MsgKey.NUM_DEVICES)
         if num_devices_to_add is None:
             num_devices_to_add = 1
         self.num_devices += num_devices_to_add
@@ -64,4 +64,4 @@ def reset(self, fl_ctx: FLContext):
         self.num_devices = 0
 
     def aggregate(self, fl_ctx: FLContext) -> Shareable:
-        return Shareable({MsgKey.RESULT: self.weights, "num_devices": self.num_devices})
+        return Shareable({MsgKey.RESULT: self.weights, MsgKey.NUM_DEVICES: self.num_devices})
diff --git a/nvflare/edge/aggregators/edge_result_accumulator.py b/nvflare/edge/aggregators/edge_result_accumulator.py
index 25f6a3882f..3e8a5bde09 100644
--- a/nvflare/edge/aggregators/edge_result_accumulator.py
+++ b/nvflare/edge/aggregators/edge_result_accumulator.py
@@ -16,6 +16,7 @@
 from nvflare.apis.fl_context import FLContext
 from nvflare.apis.shareable import Shareable
 from nvflare.app_common.abstract.aggregator import Aggregator
+from nvflare.edge.constants import MsgKey
 
 
 class EdgeResultAccumulator(Aggregator):
@@ -27,7 +28,7 @@ def __init__(self):
     def accept(self, shareable: Shareable, fl_ctx: FLContext) -> bool:
         self.log_info(fl_ctx, f"Accepting: {shareable}")
 
-        w = shareable.get("weights")
+        w = shareable.get(MsgKey.PAYLOAD)
         if w is None:
             return True
 
@@ -47,4 +48,4 @@ def reset(self, fl_ctx: FLContext):
         self.num_devices = 0
 
     def aggregate(self, fl_ctx: FLContext) -> Shareable:
-        return Shareable({"weights": self.weights, "num_devices": self.num_devices})
+        return Shareable({MsgKey.RESULT: self.weights, MsgKey.NUM_DEVICES: self.num_devices})
diff --git a/nvflare/edge/constants.py b/nvflare/edge/constants.py
index 139eae6ced..0372229e4a 100644
--- a/nvflare/edge/constants.py
+++ b/nvflare/edge/constants.py
@@ -39,3 +39,19 @@ class EdgeEventType:
 class MsgKey:
     PAYLOAD = "payload"
     RESULT = "result"
+    NUM_DEVICES = "num_devices"
+    TASK_ID = "task_id"
+    TASK_DONE = "task_done"
+
+
+class EdgeApiStatus:
+    OK = "OK"
+    RETRY = "RETRY"
+    DONE = "DONE"
+    ERROR = "ERROR"
+    NO_JOB = "NO_JOB"
+    NO_TASK = "NO_TASK"
+
+
+class EdgeApiKey:
+    JOB_DONE = "_job_done"
diff --git a/nvflare/edge/emulator/README.md b/nvflare/edge/emulator/README.md
new file mode 100644
index 0000000000..119854057d
--- /dev/null
+++ b/nvflare/edge/emulator/README.md
@@ -0,0 +1,59 @@
+# Edge Emulator
+
+Edge emulator can be used to simulate multiple devices.
+
+## Usage
+
+The emulator can be started like this,
+
+     python nvflare/edge/web/run_emulator.py config_json_file
+
+## Configuration File
+
+The `config_json_file` is a json file that defines the configuration of the emulator, with
+following format:
+
+```
+{
+  "endpoint": "http://localhost:4321",
+  "num_devices": 16,
+  "device_id_prefix": "emulator-",
+  "processor": {
+    "path": "sample_task_processor.SampleTaskProcessor",
+    "args": {
+      "data_file": "{device_id}/data/data-{user_id}.csv",
+      "parameters": {
+        "a": 123,
+        "b": 456
+      }
+    }
+  },
+  "capabilities": {
+    "methods": ["cnn"]
+  }
+}
+
+```
+
+The `endpoint`, `num_devices` and `capabilities` are self-explanatory. 
+
+The `processor` defines the task processor that simulates the device's handling of tasks. This is a dictionary 
+with following keys,
+* `path`: The full path of the processor class
+* `args`: A dictionary of arguments to be passed to the processor's constructor.
+* `pypthon_path` defines where to find the processor module. If missing, the folder where the config file is will be used.
+
+## Variable Substitution
+
+The args of the processor can contain variables. They will be substituted with the device specific information.
+
+Variable are enclosed with curly braces, like `{device_id}`.
+
+Currently, only following variables are supported,
+
+* `{device_id}`: the device ID
+* `{user_id}`: the user ID
+
+This can be used to support different data files for different devices, like
+
+```{"data_file": "{device_id}/data/data-{user_id}.csv"}```
diff --git a/nvflare/edge/emulator/config.py b/nvflare/edge/emulator/config.py
new file mode 100644
index 0000000000..3c129c4332
--- /dev/null
+++ b/nvflare/edge/emulator/config.py
@@ -0,0 +1,127 @@
+# Copyright (c) 2025, NVIDIA CORPORATION.  All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import builtins
+import importlib
+import json
+import os
+import re
+import sys
+from typing import Any, Type
+
+from nvflare.edge.emulator.device_task_processor import DeviceTaskProcessor
+
+VAR_PATTERN = re.compile(r"\{(.*?)}")
+
+
+def load_class(class_path) -> Type:
+
+    try:
+        if "." in class_path:
+            module_name, class_name = class_path.rsplit(".", 1)
+            module = importlib.import_module(module_name)
+            return getattr(module, class_name)
+        else:
+            return getattr(builtins, class_path)
+    except Exception as ex:
+        raise TypeError(f"Can't load class {class_path}: {ex}")
+
+
+class ConfigParser:
+
+    def __init__(self, config_file: str):
+        self.processor = None
+        self.endpoint = None
+        self.num_devices = 16
+        self.capabilities = {}
+        self.device_id_prefix = None
+        self.processor_class = None
+        self.processor_args = None
+
+        self.parse(config_file)
+
+    def get_processor(self, variables: dict = None) -> DeviceTaskProcessor:
+
+        if self.processor_args:
+            args = self._variable_substitution(self.processor_args, variables)
+        else:
+            args = {}
+
+        return self.processor_class(**args)
+
+    def get_endpoint(self):
+        return self.endpoint
+
+    def get_num_devices(self):
+        return self.num_devices
+
+    def get_capabilities(self):
+        return self.capabilities
+
+    def get_device_id_prefix(self):
+        return self.device_id_prefix
+
+    def parse(self, config_file: str):
+        with open(config_file, "r") as f:
+            config = json.load(f)
+
+        # Load processor
+        processor_config = config.get("processor", None)
+        if processor_config is None:
+            raise ValueError("processor is not defined in config file")
+
+        path = processor_config.get("python_path", None)
+        if not path:
+            # If no python_path defined, use the folder where the config file is
+            path = os.path.abspath(os.path.dirname(config_file))
+        sys.path.append(path)
+
+        path = processor_config.get("path")
+        if path is None:
+            raise ValueError("path for processor is not defined in config file")
+
+        self.processor_args = processor_config.get("args", {})
+        self.processor_class = load_class(path)
+        if not issubclass(self.processor_class, DeviceTaskProcessor):
+            raise TypeError(f"Processor {path} is not a subclass of DeviceTaskProcessor")
+
+        self.endpoint = config.get("endpoint", None)
+
+        n = config.get("num_devices", None)
+        if n:
+            self.num_devices = n
+
+        self.capabilities = config.get("capabilities", {})
+        self.device_id_prefix = config.get("device_id_prefix", None)
+
+    def _variable_substitution(self, args: Any, variables: dict) -> Any:
+        if isinstance(args, dict):
+            return {k: self._variable_substitution(v, variables) for k, v in args.items()}
+        elif isinstance(args, list):
+            return [self._variable_substitution(v, variables) for v in args]
+        elif isinstance(args, str):
+            result = args
+            offset = 0
+            for i, match in enumerate(VAR_PATTERN.finditer(result)):
+                start, end = match.span()
+                start += offset
+                end += offset
+                var = match.group(1)
+                if var in variables:
+                    var_value = variables.get(var)
+                    result = result[:start] + var_value + result[end:]
+                    offset += len(var_value) - (end - start)
+
+            return result
+        else:
+            return args
diff --git a/nvflare/edge/emulator/device_emulator.py b/nvflare/edge/emulator/device_emulator.py
index 095af9eb2d..b8f8ce6639 100644
--- a/nvflare/edge/emulator/device_emulator.py
+++ b/nvflare/edge/emulator/device_emulator.py
@@ -15,10 +15,10 @@
 import time
 from typing import Optional
 
+from nvflare.edge.constants import EdgeApiKey, EdgeApiStatus
 from nvflare.edge.emulator.device_task_processor import DeviceTaskProcessor
 from nvflare.edge.emulator.feg_api import FegApi
 from nvflare.edge.web.models.api_error import ApiError
-from nvflare.edge.web.models.base_model import ApiKey
 from nvflare.edge.web.models.device_info import DeviceInfo
 from nvflare.edge.web.models.job_response import JobResponse
 from nvflare.edge.web.models.task_response import TaskResponse
@@ -46,7 +46,7 @@ def __init__(
     def run(self):
         try:
             job = self.fetch_job()
-            self.processor.setup(job)
+            self.processor.setup(self.device_info, self.user_info, job)
             log.info(f"Received job: {job}")
 
             while True:
@@ -66,8 +66,13 @@ def run(self):
                 if task_done or result_response.status == "DONE":
                     log.info(f"Job {job.job_id} {job.job_name} is done")
                     break
-                elif result_response.status != "OK":
-                    log.error(f"Device:{self.device_id} Result report for task {task.task_name} is invalid")
+                elif result_response.status == EdgeApiStatus.RETRY:
+                    continue
+                elif result_response.status != EdgeApiStatus.OK:
+                    log.error(
+                        f"Device:{self.device_id} Result report for task {task.task_name}"
+                        f" is invalid. Status: {result_response.status}"
+                    )
                     continue
 
                 log.info(f"Device:{self.device_id} Task {task.task_name} result reported successfully")
@@ -85,7 +90,7 @@ def fetch_job(self) -> JobResponse:
                 if job.status == "OK":
                     return job
                 elif job.status == "DONE":
-                    job[ApiKey.JOB_DONE] = True
+                    job[EdgeApiKey.JOB_DONE] = True
                     return job
                 if job.status == "RETRY":
                     wait = job.retry_wait if job.retry_wait else 5
@@ -95,7 +100,7 @@ def fetch_job(self) -> JobResponse:
                 log.error(f"Request error. Status: {error.status}\nMessage: {str(error)}\nDetails: {error.details}")
                 time.sleep(5)
 
-    def fetch_task(self, job: JobResponse) -> TaskResponse:
+    def fetch_task(self, job: JobResponse) -> Optional[TaskResponse]:
         while True:
             try:
                 task = self.feg_api.get_task(job)
@@ -104,7 +109,7 @@ def fetch_task(self, job: JobResponse) -> TaskResponse:
                 elif task.status == "DONE":
                     task["task_done"] = True
                     return task
-                elif task.status == "NO_JOB":
+                elif task.status == "NO_TASK":
                     return None
                 elif task.status == "RETRY":
                     wait = task.retry_wait if task.retry_wait else 5
diff --git a/nvflare/edge/emulator/device_task_processor.py b/nvflare/edge/emulator/device_task_processor.py
index 6a41aacbab..2274cbbd5a 100644
--- a/nvflare/edge/emulator/device_task_processor.py
+++ b/nvflare/edge/emulator/device_task_processor.py
@@ -24,16 +24,14 @@ class DeviceTaskProcessor(ABC):
     The spec for a task processor that handles tasks on edge devices
     """
 
-    def __init__(self, device_info: DeviceInfo, user_info: UserInfo):
-        self.device_info = device_info
-        self.user_info = user_info
-
     @abstractmethod
-    def setup(self, job: JobResponse) -> None:
+    def setup(self, device_info: DeviceInfo, user_info: UserInfo, job: JobResponse) -> None:
         """
         Setup for a new job
 
         Args
+            device_info: Device information from request header
+            user_info: User information from request header
             job: Job information returned by server
         """
         pass
diff --git a/nvflare/edge/emulator/emulator_config.json b/nvflare/edge/emulator/emulator_config.json
new file mode 100644
index 0000000000..4e953e3d04
--- /dev/null
+++ b/nvflare/edge/emulator/emulator_config.json
@@ -0,0 +1,18 @@
+{
+  "endpoint": "http://localhost:4321",
+  "num_devices": 16,
+  "device_id_prefix": "emulator-",
+  "processor": {
+    "path": "sample_task_processor.SampleTaskProcessor",
+    "args": {
+      "data_file": "{device_id}/data/data-{user_id}.csv",
+      "parameters": {
+        "a": 123,
+        "b": 456
+      }
+    }
+  },
+  "capabilities": {
+    "methods": ["cnn"]
+  }
+}
diff --git a/nvflare/edge/emulator/run_emulator.py b/nvflare/edge/emulator/run_emulator.py
index 6f1291d09d..04d26e1152 100644
--- a/nvflare/edge/emulator/run_emulator.py
+++ b/nvflare/edge/emulator/run_emulator.py
@@ -11,14 +11,12 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
 import argparse
-import importlib
-import json
 import logging
 import traceback
 from concurrent.futures import ThreadPoolExecutor, wait
 
+from nvflare.edge.emulator.config import ConfigParser
 from nvflare.edge.emulator.device_emulator import DeviceEmulator
 from nvflare.edge.emulator.device_task_processor import DeviceTaskProcessor
 from nvflare.edge.web.models.device_info import DeviceInfo
@@ -26,53 +24,12 @@
 
 log = logging.getLogger(__name__)
 
-from typing import Any, Dict, Tuple, Type
-
-
-def load_class_from_config(config_path: str) -> Tuple[Type[Any], Dict[str, Any]]:
-    """Load class and config from JSON file.
-
-    Args:
-        config_path: Path to JSON config file
-
-    Returns:
-        tuple: (class, config_dict)
-
-    Raises:
-        FileNotFoundError: If config file doesn't exist
-        KeyError: If required fields are missing
-        ImportError: If class cannot be imported
-    """
-    with open(config_path) as f:
-        config = json.load(f)
-
-    if "class_path" not in config:
-        raise KeyError("Config must contain 'class_path'")
-
-    try:
-        module_path, class_name = config["class_path"].rsplit(".", 1)
-        module = importlib.import_module(module_path)
-        cls = getattr(module, class_name)
-    except (ValueError, ImportError, AttributeError) as e:
-        raise ImportError(f"Failed to import {config['class_path']}: {e}")
-
-    return cls, config.get("class_args", {})
-
-
-def create_task_processor(filename: str, device_info, user_info):
-    try:
-        cls, config = load_class_from_config(filename)
-        processor = cls(device_info, user_info, **config)
-        return processor
-    except Exception as e:
-        logging.error(f"Failed to create task processor {filename}: {e}")
-        raise
-
 
-def device_run(endpoint_url: str, device_info: DeviceInfo, user_info: UserInfo, processor: DeviceTaskProcessor):
+def device_run(
+    endpoint_url: str, device_info: DeviceInfo, user_info: UserInfo, capabilities: dict, processor: DeviceTaskProcessor
+):
     device_id = device_info.device_id
     try:
-        capabilities = {"methods": ["xor", "cifar10"], "cpu": 16, "gpu": 1024}
         emulator = DeviceEmulator(endpoint_url, device_info, user_info, capabilities, processor)
         emulator.run()
 
@@ -82,14 +39,26 @@ def device_run(endpoint_url: str, device_info: DeviceInfo, user_info: UserInfo,
         log.error(f"Device {device_id} failed to run: {ex}")
 
 
-def run_emulator(endpoint_url: str, num: int, emulator_config: str):
+def run_emulator(config_file: str):
+    parser = ConfigParser(config_file)
+    num = parser.get_num_devices()
+    endpoint_url = parser.get_endpoint()
+    log.info(f"Running {num} devices. Endpoint URL: {endpoint_url}")
+
     with ThreadPoolExecutor(max_workers=num) as thread_pool:
         futures = []
         for i in range(num):
-            device_info = DeviceInfo(f"device-{i}", "flare_mobile", "1.0")
+            prefix = parser.get_device_id_prefix()
+            if not prefix:
+                prefix = "device-"
+            device_id = f"{prefix}{i}"
+            device_info = DeviceInfo(f"{device_id}", "flare_mobile", "1.0")
             user_info = UserInfo("demo_id", "demo_user")
-            processor = create_task_processor(emulator_config, device_info, user_info)
-            f = thread_pool.submit(device_run, endpoint_url, device_info, user_info, processor)
+            variables = {"device_id": device_id, "user_id": user_info.user_id}
+            processor = parser.get_processor(variables)
+            f = thread_pool.submit(
+                device_run, endpoint_url, device_info, user_info, parser.get_capabilities(), processor
+            )
             futures.append(f)
 
         wait(futures)
@@ -106,23 +75,19 @@ def main():
     )
 
     # Set up argument parser
-    parser = argparse.ArgumentParser(description="Run NVFlare emulator")
+    parser = argparse.ArgumentParser(description="Run NVFlare edge device emulator")
 
     parser.add_argument(
-        "--endpoint",
+        "config_file",
         type=str,
-        default="http://localhost:9007",
-        help="Server endpoint URL (default: http://localhost:9007)",
+        help="Location of JSON configuration file",
     )
 
-    parser.add_argument("--num-devices", type=int, default=4, help="Number of devices to emulate (default: 4)")
-    parser.add_argument("--emulator_config", type=str, default="", help="Emulator config file")
-
     # Parse arguments
     args = parser.parse_args()
 
     # Run emulator
-    run_emulator(args.endpoint, args.num_devices, args.emulator_config)
+    run_emulator(args.config_file)
 
 
 if __name__ == "__main__":
diff --git a/nvflare/edge/emulator/sample_task_processor.py b/nvflare/edge/emulator/sample_task_processor.py
index ae8cce5d49..e936926444 100644
--- a/nvflare/edge/emulator/sample_task_processor.py
+++ b/nvflare/edge/emulator/sample_task_processor.py
@@ -12,7 +12,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 import logging
+from typing import Optional
 
+from nvflare.edge.constants import MsgKey
 from nvflare.edge.emulator.device_task_processor import DeviceTaskProcessor
 from nvflare.edge.web.models.device_info import DeviceInfo
 from nvflare.edge.web.models.job_response import JobResponse
@@ -23,12 +25,13 @@
 
 
 class SampleTaskProcessor(DeviceTaskProcessor):
-    def __init__(self, device_info: DeviceInfo, user_info: UserInfo):
-        super().__init__(device_info, user_info)
+    def __init__(self, data_file: str, parameters: Optional[dict]):
+        self.data_file = data_file
+        self.parameters = parameters
         self.job_id = None
         self.job_name = None
 
-    def setup(self, job: JobResponse) -> None:
+    def setup(self, device_info: DeviceInfo, user_info: UserInfo, job: JobResponse) -> None:
         self.job_id = job.job_id
         self.job_name = job.job_name
         # job.job_data contains data needed to set up the training
@@ -41,12 +44,12 @@ def process_task(self, task: TaskResponse) -> dict:
 
         result = None
         if task.task_name == "train":
-            weights = task.task_data["weights"]
+            weights = task.task_data[MsgKey.PAYLOAD]
             if weights:
                 w = [x * 2.0 for x in weights]
             else:
                 w = [0, 0, 0, 0]
-            result = {"weights": w}
+            result = {MsgKey.RESULT: w}
         elif task.task_name == "validate":
             result = {"accuracy": [0.01, 0.02, 0.03, 0.04]}
         else:
diff --git a/nvflare/edge/examples/jobs/hello_mobile/app/custom/edge_controller.py b/nvflare/edge/examples/jobs/hello_mobile/app/custom/edge_controller.py
index 6033476fd4..63d7bb828c 100644
--- a/nvflare/edge/examples/jobs/hello_mobile/app/custom/edge_controller.py
+++ b/nvflare/edge/examples/jobs/hello_mobile/app/custom/edge_controller.py
@@ -22,6 +22,7 @@
 from nvflare.app_common.app_constant import AppConstants
 from nvflare.app_common.app_event_type import AppEventType
 from nvflare.edge.aggregators.edge_result_accumulator import EdgeResultAccumulator
+from nvflare.edge.constants import MsgKey
 from nvflare.security.logging import secure_format_exception
 
 
@@ -66,8 +67,8 @@ def control_flow(self, abort_signal: Signal, fl_ctx: FLContext) -> None:
 
                 # Create train_task
                 task_data = Shareable()
-                task_data["weights"] = weights
-                task_data["task_done"] = self.current_round >= (self.num_rounds - 1)
+                task_data[MsgKey.PAYLOAD] = weights
+                task_data[MsgKey.TASK_DONE] = self.current_round >= (self.num_rounds - 1)
                 task_data.set_header(AppConstants.CURRENT_ROUND, self.current_round)
                 task_data.set_header(AppConstants.NUM_ROUNDS, self.num_rounds)
                 task_data.add_cookie(AppConstants.CONTRIBUTION_ROUND, self.current_round)
@@ -92,7 +93,7 @@ def control_flow(self, abort_signal: Signal, fl_ctx: FLContext) -> None:
                 self.log_info(fl_ctx, "Start aggregation.")
                 self.fire_event(AppEventType.BEFORE_AGGREGATION, fl_ctx)
                 aggr_result = self.aggregator.aggregate(fl_ctx)
-                weights = aggr_result.get("weights")
+                weights = aggr_result.get(MsgKey.RESULT)
                 self.log_info(fl_ctx, f"Aggregation result: {aggr_result}")
                 fl_ctx.set_prop(AppConstants.AGGREGATION_RESULT, aggr_result, private=True, sticky=False)
                 self.fire_event(AppEventType.AFTER_AGGREGATION, fl_ctx)
@@ -101,7 +102,7 @@ def control_flow(self, abort_signal: Signal, fl_ctx: FLContext) -> None:
                 if abort_signal.triggered:
                     return
 
-            final_weights = aggr_result.get("weights", None)
+            final_weights = aggr_result.get(MsgKey.RESULT, None)
             self.log_info(fl_ctx, f"Finished Mobile Training. Final weights: {final_weights}")
         except Exception as e:
             error_msg = f"Exception in mobile control_flow: {secure_format_exception(e)}"
diff --git a/nvflare/edge/executors/edge_dispatch_executor.py b/nvflare/edge/executors/edge_dispatch_executor.py
index 1dd31fee1f..df597b26be 100644
--- a/nvflare/edge/executors/edge_dispatch_executor.py
+++ b/nvflare/edge/executors/edge_dispatch_executor.py
@@ -17,15 +17,15 @@
 
 from nvflare.apis.event_type import EventType
 from nvflare.apis.fl_context import FLContext
-from nvflare.apis.shareable import ReservedHeaderKey, ReturnCode, Shareable
-from nvflare.edge.aggregators.edge_result_accumulator import EdgeResultAccumulator
-from nvflare.edge.constants import MsgKey
+from nvflare.apis.shareable import ReservedHeaderKey, ReturnCode, Shareable, make_reply
+from nvflare.edge.constants import EdgeApiStatus, MsgKey
 from nvflare.edge.executors.ete import EdgeTaskExecutor
 from nvflare.edge.web.models.result_report import ResultReport
 from nvflare.edge.web.models.result_response import ResultResponse
 from nvflare.edge.web.models.task_request import TaskRequest
 from nvflare.edge.web.models.task_response import TaskResponse
 from nvflare.fuel.utils.validation_utils import check_non_negative_int, check_non_negative_number
+from nvflare.security.logging import secure_format_exception
 
 
 class EdgeDispatchExecutor(EdgeTaskExecutor):
@@ -51,15 +51,17 @@ def __init__(self, wait_time=300.0, min_devices=0, aggregator_id=None):
         self.register_event_handler(EventType.START_RUN, self.setup)
 
     def setup(self, _event_type, fl_ctx: FLContext):
-        if self.aggregator_id:
-            self.aggregator = fl_ctx.get_engine().get_component(self.aggregator_id)
-        else:
-            self.aggregator = EdgeResultAccumulator()
+        if not self.aggregator_id:
+            raise ValueError("aggregator_id is not set")
+
+        self.aggregator = fl_ctx.get_engine().get_component(self.aggregator_id)
+        if not self.aggregator:
+            raise RuntimeError(f"Can't find aggregator: {self.aggregator_id}")
 
     def convert_task(self, task_data: Shareable) -> dict:
         """Convert task_data to a plain dict"""
 
-        return {MsgKey.PAYLOAD: task_data[MsgKey.PAYLOAD], "task_id": self.task_id}
+        return {MsgKey.PAYLOAD: task_data[MsgKey.PAYLOAD], MsgKey.TASK_ID: self.task_id}
 
     def convert_result(self, result: dict) -> Shareable:
         """Convert result from device to shareable"""
@@ -93,10 +95,15 @@ def handle_result_report(self, report: ResultReport, fl_ctx: FLContext) -> Resul
             # Still returns OK because this late result may be useful in certain cases
             return ResultResponse("OK", task_id=self.task_id, task_name=self.task_name, message=msg)
 
-        result = self.convert_result(report.result)
-        self.aggregator.accept(result, fl_ctx)
-        self.num_results += 1
-        return ResultResponse("OK", task_id=self.task_id, task_name=self.task_name)
+        try:
+            result = self.convert_result(report.result)
+            self.aggregator.accept(result, fl_ctx)
+            self.num_results += 1
+            return ResultResponse(EdgeApiStatus.OK, task_id=self.task_id, task_name=self.task_name)
+        except Exception as ex:
+            msg = f"exception when 'accept' from aggregator {type(self.aggregator)}: {secure_format_exception(ex)}"
+            self.log_error(fl_ctx, msg)
+            return ResultResponse(EdgeApiStatus.ERROR, task_id=self.task_id, task_name=self.task_name, message=msg)
 
     def task_received(self, task_name: str, task_data: Shareable, fl_ctx: FLContext):
         # Reset aggregator
@@ -124,4 +131,9 @@ def process_edge_request(self, request: Any, fl_ctx: FLContext) -> Any:
         return {"status": ReturnCode.OK, "response": response}
 
     def get_task_result(self, fl_ctx: FLContext) -> Shareable:
-        return self.aggregator.aggregate(fl_ctx)
+        try:
+            self.log_info(fl_ctx, "return aggregation result")
+            return self.aggregator.aggregate(fl_ctx)
+        except Exception as ex:
+            self.log_error(fl_ctx, f"Error from {type(self.aggregator)}: {secure_format_exception(ex)}")
+            return make_reply(ReturnCode.EXECUTION_EXCEPTION)
diff --git a/nvflare/edge/web/handlers/lcp_task_handler.py b/nvflare/edge/web/handlers/lcp_task_handler.py
index 36038e53e5..6577141bfa 100644
--- a/nvflare/edge/web/handlers/lcp_task_handler.py
+++ b/nvflare/edge/web/handlers/lcp_task_handler.py
@@ -16,9 +16,8 @@
 
 from nvflare.apis.client_engine_spec import ClientEngineSpec
 from nvflare.apis.fl_constant import FLContextKey
-from nvflare.edge.constants import EdgeContextKey, EdgeEventType, EdgeProtoKey, Status
+from nvflare.edge.constants import EdgeApiStatus, EdgeContextKey, EdgeEventType, EdgeProtoKey, Status
 from nvflare.edge.web.handlers.edge_task_handler import EdgeTaskHandler
-from nvflare.edge.web.models.base_model import ApiStatus
 from nvflare.edge.web.models.job_request import JobRequest
 from nvflare.edge.web.models.job_response import JobResponse
 from nvflare.edge.web.models.result_report import ResultReport
@@ -45,7 +44,7 @@ def handle_job(self, job_request: JobRequest) -> JobResponse:
             assert isinstance(reply, dict)
             status = reply.get(EdgeProtoKey.STATUS)
             if status != Status.OK:
-                response = JobResponse(ApiStatus.RETRY, retry_wait=30)
+                response = JobResponse(EdgeApiStatus.RETRY, retry_wait=30)
             else:
                 job_id = reply.get(EdgeProtoKey.DATA)
                 job_meta = fl_ctx.get_prop(FLContextKey.JOB_META)
@@ -53,7 +52,7 @@ def handle_job(self, job_request: JobRequest) -> JobResponse:
                     job_name = job_meta.get("name")
                 else:
                     job_name = "No Name"
-                response = JobResponse(ApiStatus.OK, job_id=job_id, job_name=job_name, job_meta=job_meta)
+                response = JobResponse(EdgeApiStatus.OK, job_id=job_id, job_name=job_name, job_meta=job_meta)
 
             return response
 
@@ -65,13 +64,13 @@ def handle_task(self, task_request: TaskRequest) -> TaskResponse:
             response = data.get("response")
         elif status == Status.NO_JOB:
             self.logger.info(f"Job {task_request.job_id} is done")
-            response = TaskResponse(ApiStatus.NO_JOB, retry_wait=30)
+            response = TaskResponse(EdgeApiStatus.NO_JOB, retry_wait=30)
         elif status == Status.NO_TASK:
             self.logger.info(f"Job {task_request.job_id} has no task")
-            response = TaskResponse(ApiStatus.NO_TASK, retry_wait=30)
+            response = TaskResponse(EdgeApiStatus.NO_TASK, retry_wait=30)
         else:
             self.logger.error(f"Task request for {task_request.job_id} failed with status {status}")
-            response = TaskResponse(ApiStatus.RETRY, retry_wait=30)
+            response = TaskResponse(EdgeApiStatus.RETRY, retry_wait=30)
 
         return response
 
@@ -79,7 +78,7 @@ def handle_result(self, result_report: ResultReport) -> ResultResponse:
         reply = self._handle_task_request(result_report)
         status = reply.get(EdgeProtoKey.STATUS)
         if status != Status.OK:
-            response = ResultResponse(ApiStatus.RETRY, retry_wait=30)
+            response = ResultResponse(EdgeApiStatus.RETRY, retry_wait=30)
         else:
             data = reply.get(EdgeProtoKey.DATA)
             response = data.get("response")
diff --git a/nvflare/edge/web/models/base_model.py b/nvflare/edge/web/models/base_model.py
index c4ce57b787..a00473ec13 100644
--- a/nvflare/edge/web/models/base_model.py
+++ b/nvflare/edge/web/models/base_model.py
@@ -15,18 +15,6 @@
 from urllib.parse import parse_qs, urlencode
 
 
-class ApiStatus:
-    OK = "OK"
-    RETRY = "RETRY"
-    DONE = "DONE"
-    NO_JOB = "NO_JOB"
-    NO_TASK = "NO_TASK"
-
-
-class ApiKey:
-    JOB_DONE = "_job_done"
-
-
 class BaseModel(dict):
     """Dictionary based model"""
 


Reviews:
user: login: yanchengnv
body: Good enough for now. See my comments for improvement.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T22:10:53Z
user: login: nvidianz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T20:31:10Z
user: login: nvidianz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T21:39:19Z
user: login: nvidianz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T21:39:57Z
user: login: YuanTingHsieh
body: LGTM, I'll refactor my PR for those task processor afterwards 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-07T22:35:04Z
user: login: nvidianz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-08T00:09:14Z

Review comments:
diff_hunk: @@ -0,0 +1,41 @@
+# Edge Emulator
+
+Edge emulator can be used to simulate multiple devices.
+
+## Usage
+
+The emulator can be started like this,
+
+     python nvflare/edge/web/run_emulator.py config_json_file
+
+## Configuration File
+
+The `config_json_file` is a json file that defines the configuration of the emulator, with
+following format:
+
+```
+{
+  "endpoint": "http://localhost:4321",
+  "num_devices": 16,
+  "processor": {
+    "path": "sample_task_processor.SampleTaskProcessor",
+    "python_path": "/opt/nvflare/edge/web",
+    "args": {
+      "data_file": "data/data.csv",
path: nvflare/edge/emulator/README.md
user: login: yanchengnv
body: Will all devices use the same data? Each device should have its own data.
created_at: 2025-03-05T16:24:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+# Copyright (c) 2025, NVIDIA CORPORATION.  All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import builtins
+import importlib
+import json
+import os
+import sys
+
+from nvflare.edge.emulator.device_task_processor import DeviceTaskProcessor
+
+
+def load_class(class_path):
+
+    try:
+        if "." in class_path:
+            module_name, class_name = class_path.rsplit(".", 1)
+            module = importlib.import_module(module_name)
+            return getattr(module, class_name)
+        else:
+            return getattr(builtins, class_path)
+    except Exception as ex:
+        raise TypeError(f"Can't load class {class_path}: {ex}")
+
+
+class ConfigParser:
+
+    def __init__(self, config_file: str):
+        self.processor = None
+        self.endpoint = None
+        self.num_devices = 16
+
+        self.parse(config_file)
+
+    def get_processor(self):
+        return self.processor
+
+    def get_endpoint(self):
+        return self.endpoint
+
+    def get_num_devices(self):
+        return self.num_devices
+
+    def parse(self, config_file: str):
+        with open(config_file, "r") as f:
+            config = json.load(f)
+
+        # Load processor
+        processor_config = config.get("processor", None)
+        if processor_config is None:
+            raise ValueError("processor is not defined in config file")
+
+        path = processor_config.get("python_path", None)
+        if not path:
+            # If no python_path defined, use the folder where the config file is
+            path = os.path.abspath(os.path.dirname(config_file))
+        sys.path.append(path)
+
+        path = processor_config.get("path")
+        if path is None:
+            raise ValueError("path for processor is not defined in config file")
+
+        processor_args = processor_config.get("args", {})
+        process_class = load_class(path)
path: nvflare/edge/emulator/config.py
user: login: yanchengnv
body: Should it be processor_class, rather than process_class?
created_at: 2025-03-05T16:28:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,14 @@
+{
+  "endpoint": "http://localhost:4321",
+  "num_devices": 16,
+  "processor": {
+    "path": "sample_task_processor.SampleTaskProcessor",
+    "args": {
+      "data_file": "data/data.csv",
path: nvflare/edge/emulator/emulator_config.json
user: login: yanchengnv
body: We need to somehow allow different devices to use different data file.
created_at: 2025-03-05T22:07:08Z
author_association: COLLABORATOR
diff_hunk: @@ -59,14 +64,8 @@ def run_emulator(endpoint_url: str, num: int):
     )
 
     n = len(sys.argv)
-    if n >= 2:
-        endpoint = sys.argv[1]
-    else:
-        endpoint = "http://localhost:9007"
-
-    if n >= 3:
-        num_devices = int(sys.argv[2])
-    else:
-        num_devices = 4
+    if n != 2:
+        print("Usage: python run_emulator.py <config_file>")
+        sys.exit(1)
 
-    run_emulator(endpoint, num_devices)
+    run_emulator(sys.argv[1])
path: nvflare/edge/emulator/run_emulator.py
user: login: yanchengnv
body: I wonder why you don't user Python's argparse?
created_at: 2025-03-05T22:09:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,84 @@
+# Copyright (c) 2025, NVIDIA CORPORATION.  All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import builtins
+import importlib
+import json
+import os
+import sys
+
+from nvflare.edge.emulator.device_task_processor import DeviceTaskProcessor
+
+
+def load_class(class_path):
+
+    try:
+        if "." in class_path:
+            module_name, class_name = class_path.rsplit(".", 1)
+            module = importlib.import_module(module_name)
+            return getattr(module, class_name)
+        else:
+            return getattr(builtins, class_path)
+    except Exception as ex:
+        raise TypeError(f"Can't load class {class_path}: {ex}")
+
+
+class ConfigParser:
+
+    def __init__(self, config_file: str):
+        self.processor = None
+        self.endpoint = None
+        self.num_devices = 16
+
+        self.parse(config_file)
+
+    def get_processor(self):
+        return self.processor
+
+    def get_endpoint(self):
+        return self.endpoint
+
+    def get_num_devices(self):
+        return self.num_devices
+
+    def parse(self, config_file: str):
+        with open(config_file, "r") as f:
+            config = json.load(f)
+
+        # Load processor
+        processor_config = config.get("processor", None)
+        if processor_config is None:
+            raise ValueError("processor is not defined in config file")
+
+        path = processor_config.get("python_path", None)
+        if not path:
+            # If no python_path defined, use the folder where the config file is
+            path = os.path.abspath(os.path.dirname(config_file))
+        sys.path.append(path)
+
+        path = processor_config.get("path")
+        if path is None:
+            raise ValueError("path for processor is not defined in config file")
+
+        processor_args = processor_config.get("args", {})
+        process_class = load_class(path)
path: nvflare/edge/emulator/config.py
user: login: nvidianz
body: Fixed.
created_at: 2025-03-07T20:31:10Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,14 @@
+{
+  "endpoint": "http://localhost:4321",
+  "num_devices": 16,
+  "processor": {
+    "path": "sample_task_processor.SampleTaskProcessor",
+    "args": {
+      "data_file": "data/data.csv",
path: nvflare/edge/emulator/emulator_config.json
user: login: nvidianz
body: Added variable substitution to support this.
created_at: 2025-03-07T21:39:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,41 @@
+# Edge Emulator
+
+Edge emulator can be used to simulate multiple devices.
+
+## Usage
+
+The emulator can be started like this,
+
+     python nvflare/edge/web/run_emulator.py config_json_file
+
+## Configuration File
+
+The `config_json_file` is a json file that defines the configuration of the emulator, with
+following format:
+
+```
+{
+  "endpoint": "http://localhost:4321",
+  "num_devices": 16,
+  "processor": {
+    "path": "sample_task_processor.SampleTaskProcessor",
+    "python_path": "/opt/nvflare/edge/web",
+    "args": {
+      "data_file": "data/data.csv",
path: nvflare/edge/emulator/README.md
user: login: nvidianz
body: Added variable substitution to handle this.
created_at: 2025-03-07T21:39:57Z
author_association: COLLABORATOR
diff_hunk: @@ -59,14 +64,8 @@ def run_emulator(endpoint_url: str, num: int):
     )
 
     n = len(sys.argv)
-    if n >= 2:
-        endpoint = sys.argv[1]
-    else:
-        endpoint = "http://localhost:9007"
-
-    if n >= 3:
-        num_devices = int(sys.argv[2])
-    else:
-        num_devices = 4
+    if n != 2:
+        print("Usage: python run_emulator.py <config_file>")
+        sys.exit(1)
 
-    run_emulator(endpoint, num_devices)
+    run_emulator(sys.argv[1])
path: nvflare/edge/emulator/run_emulator.py
user: login: nvidianz
body: Changed to user argparse.
created_at: 2025-03-08T00:09:14Z
author_association: COLLABORATOR

PR comments:
user: login: nvidianz
created_at: 2025-03-05T02:35:39Z
body: /build
author_association: COLLABORATOR
user: login: nvidianz
created_at: 2025-03-08T00:05:22Z
body: /build
author_association: COLLABORATOR