diff --git a/PyAres/Analyzing/__init__.py b/PyAres/Analyzing/__init__.py index 3a485f4..0a80f7f 100644 --- a/PyAres/Analyzing/__init__.py +++ b/PyAres/Analyzing/__init__.py @@ -1,9 +1,10 @@ from .analysis_service import AresAnalyzerService -from .analyzer_models import AnalysisResponse, AnalysisRequest, InfoResponse +from .analyzer_models import AnalysisResponse, AnalysisRequest, InfoResponse, Objective __all__ = [ "AnalysisResponse", "AnalysisRequest", "InfoResponse", "AresAnalyzerService", + "Objective" ] \ No newline at end of file diff --git a/PyAres/Analyzing/analysis_service.py b/PyAres/Analyzing/analysis_service.py index 61ec03f..15fa200 100644 --- a/PyAres/Analyzing/analysis_service.py +++ b/PyAres/Analyzing/analysis_service.py @@ -14,6 +14,7 @@ from ..Utils import ares_struct_utils from ..Utils import ares_data_schema_utils from ..Utils import ares_outcome_utils +from ..Utils import ares_value_utils from ..Utils.ares_service_base import AresServiceWrapperBase, AresBaseService # Import python models @@ -31,36 +32,60 @@ def __init__(self, name: str, version: str, description: str, timeout: int, cust super().__init__(name, version, description, timeout) self._custom_analysis_logic = custom_analysis_logic self._analysis_parameters: Dict[str, ares_data_schema_pb2.AresValueSchema] = {} + self._objective_outputs: Dict[str, ares_data_schema_pb2.AresValueSchema] = {} - def Analyze(self, request: analyzer_service.AnalysisRequest, context) -> analysis_pb2.Analysis: + def Analyze(self, request: analyzer_service.AnalysisRequest, context) -> analysis_pb2.AnalysisResponse: print("Received an analysis request!") try: python_request = AnalysisRequest( inputs=ares_struct_utils.ares_struct_to_dict(request.inputs), settings=ares_struct_utils.ares_struct_to_dict(request.settings), - metadata=RequestMetadata(request.metadata)) + metadata=RequestMetadata(request.metadata), + ) python_response = self._custom_analysis_logic(python_request) python_response = self._resolve_awaitable(python_response) if not isinstance(python_response, AnalysisResponse): - print("Analysis response was an invalid type, ") - proto_analysis = analysis_pb2.Analysis() - proto_analysis.analysis_outcome = ares_outcome_enum_pb2.FAILURE - proto_analysis.error_string = "The user's custom analysis logic returned an invalid type, analysis cannot be processed" - return proto_analysis - - print("Sending Analysis Response.....") - return analysis_pb2.Analysis( - result=python_response.result, - analysis_outcome=ares_outcome_utils.python_ares_outcome_to_proto_ares_outcome(python_response.outcome), - error_string=python_response.error_string - ) - + print("Analysis response was an invalid type.") + proto_response = analysis_pb2.AnalysisResponse() + proto_response.analysis_outcome = ares_outcome_enum_pb2.FAILURE + proto_response.error_string = ( + "The user's custom analysis logic returned an invalid type; " + "expected AnalysisResponse." + ) + return proto_response + + if python_response.deprecated_result_usage: + print("WARNING: AnalysisResponse(result=...) usage is deprecated and will be " + "removed in a future major version. Please construct objectives explicitly.") + + print("Sending AnalysisResponse.....") + proto_response = analysis_pb2.AnalysisResponse() + + for obj in python_response.objectives: + obj_proto = analysis_pb2.Objective() + obj_proto.objective_name = obj.objective_name + + ares_value_utils.py_to_ares_value(obj.objective_value, obj_proto.objective_value) + + if obj.objective_metadata: + ares_struct_utils.dict_to_ares_struct(obj.objective_metadata, obj_proto.objective_metadata) + + proto_response.objectives.append(obj_proto) + + proto_response.analysis_outcome = (ares_outcome_utils.python_ares_outcome_to_proto_ares_outcome(python_response.outcome)) + proto_response.error_string = python_response.error_string + + return proto_response + except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Error in custom analysis logic: {e}") - return analysis_pb2.Analysis(analysis_outcome=ares_outcome_enum_pb2.FAILURE, error_string=str(e)) + proto_response = analysis_pb2.AnalysisResponse() + proto_response.analysis_outcome = ares_outcome_enum_pb2.FAILURE + proto_response.error_string = str(e) + return proto_response def GetAnalysisParameters(self, request, context): @@ -80,10 +105,15 @@ def GetAnalysisParameters(self, request, context): def GetAnalyzerCapabilities(self, request, context) -> analyzer_capabilities_pb2.AnalyzerCapabilities: print("Capabilities Requested!") capabilities = analyzer_capabilities_pb2.AnalyzerCapabilities(timeout_seconds=self._timeout) + try: for(key, value) in self._settings.items(): settings_entry = capabilities.settings_schema.fields[key] settings_entry.CopyFrom(value) + + for(key, value) in self._objective_outputs.items(): + objective_entry = capabilities.objective_output_schema.fields[key] + objective_entry.CopyFrom(value) return capabilities @@ -145,3 +175,10 @@ def add_analysis_parameter(self, parameter_name: str, parameter_type: ares_data_ Adds an analysis parameter that will be reported to ARES. """ self._service_wrapper._analysis_parameters[parameter_name] = ares_data_schema_utils.create_settings_schema_entry(parameter_type, optional, [], struct_schema) + + def add_objective_output(self, objective_name: str, objective_type: ares_data_models.AresDataType, objective_description: str = "", optional: bool = False, struct_schema: Optional[Dict[str, AresSchemaEntry]] = None): + """ + Adds an analysis objective to the advertised outputs of this analyzer. + """ + + self._service_wrapper._objective_outputs[objective_name] = ares_data_schema_utils.create_settings_schema_entry(objective_type, optional, [], struct_schema, description=objective_description) diff --git a/PyAres/Analyzing/analyzer_models.py b/PyAres/Analyzing/analyzer_models.py index 2fd90aa..73701f3 100644 --- a/PyAres/Analyzing/analyzer_models.py +++ b/PyAres/Analyzing/analyzer_models.py @@ -1,4 +1,5 @@ -from typing import Dict, Any +from typing import Dict, Any, List, Optional +import warnings from ..Models import Outcome, RequestMetadata class AnalysisRequest: @@ -20,25 +21,114 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() +class Objective: + """ Represents a single analysis objective in the new AnalysisResponse model. """ + + def __init__(self, objective_name: str, objective_value: Any, objective_metadata: Optional[Dict[str, Any]] = None): + """ + Initializes a new Objective. + + Args: + objective_name: A name the user wants associated with this objective. + objective_value: The value associated with this objective, as calculated by the analyzer. + objective_metadata: Optional metadata associated with this objective, represented as a dictionary. + """ + self.objective_name = objective_name + self.objective_value = objective_value + self.objective_metadata = objective_metadata or {} + + def __str__(self) -> str: + return (f"Objective object with:\n" + f" objective_name: {self.objective_name}\n" + f" objective_value: {self.objective_value}\n" + f" objective_metadata: {self.objective_metadata}") + + def __repr__(self) -> str: + return self.__str__() + class AnalysisResponse: - """ Represents the result of an analysis process. """ + """ Represents the result of an analysis process using objectives. + + Preferred usage: + - Construct with a list of Objective instances: + AnalysisResponse( + objectives=[ + Objective("primary_metric", 0.87, {"units": "accuracy"}) + ], + outcome=Outcome.SUCCESS, + error_string="" + ) + + Deprecated usage: + - Construct with a single scalar result (will be removed in a future major version): + AnalysisResponse(result=0.87) + """ - def __init__(self, result: float, outcome: Outcome = Outcome.SUCCESS, error_string: str = ""): + def __init__( + self, + objectives: Optional[List[Objective]] = None, + outcome: Outcome = Outcome.SUCCESS, + error_string: str = "", + result: Optional[float] = None, + ): """ - Initializes an Analysis message + Initializes an AnalysisResponse message. Args: - result: The value your analyzer returns as the result of the experiment being analyzed. Represented as a float. - success: A boolean value that represents whether analysis was done successfully. - error_string: An optional string argument for passing why analysis failed to ARES. Will default to an empty string if no value is provided. + objectives: A list of Objective instances representing the objectives returned by analysis. + outcome: An Outcome value that represents whether analysis was done successfully. + error_string: An optional string argument for passing why analysis failed to ARES. + result: DEPRECATED. A single numeric result that will be wrapped into a default Objective. + This parameter is deprecated and will be removed in a future major version. """ - self.result = result + self._deprecated_result_usage = False + + if objectives is not None and result is not None: + raise ValueError("AnalysisResponse cannot be constructed with both 'objectives' and deprecated 'result'. " + "Use 'objectives' only.") + + if objectives is None and result is not None: + # Deprecated path: single scalar result + warnings.warn( + "AnalysisResponse(result=...) is deprecated; use " + "AnalysisResponse(objectives=[Objective(...)]) instead. " + "Support will be removed in a future major version.", + DeprecationWarning + ) + self._deprecated_result_usage = True + + # Use a default objective name for backward compatibility + default_objective = Objective( + objective_name="result", + objective_value=result, + objective_metadata=None, + ) + self.objectives: List[Objective] = [default_objective] + elif objectives is not None: + self.objectives = objectives + else: + # No objectives and no result provided: treat as empty objectives + self.objectives = [] + self.outcome = outcome self.error_string = error_string + @property + def deprecated_result_usage(self) -> bool: + """Indicates whether this AnalysisResponse was created via the deprecated 'result' parameter.""" + return self._deprecated_result_usage + def __str__(self) -> str: - return (f"Analysis object with:\n" - f" result: {self.result}\n" + if not self.objectives: + objectives_str = "\t- (none)" + + else: + objectives_str = "\n".join( + [f"\t- {obj.objective_name}: value={obj.objective_value}, metadata={obj.objective_metadata}" + for obj in self.objectives]) + + return (f"AnalysisResponse object with:\n" + f" objectives:\n{objectives_str}\n" f" outcome: {self.outcome}\n" f" error_string: {self.error_string}") diff --git a/PyAres/Demo/Analyzers/analyzer_wiki.py b/PyAres/Demo/Analyzers/analyzer_wiki.py index e4ae09a..0ab80f6 100644 --- a/PyAres/Demo/Analyzers/analyzer_wiki.py +++ b/PyAres/Demo/Analyzers/analyzer_wiki.py @@ -1,4 +1,4 @@ -from PyAres import AresAnalyzerService, AnalysisRequest, AnalysisResponse, AresDataType, Outcome, Limits +from PyAres import * def analyze_sample(request: AnalysisRequest) -> AnalysisResponse: # 1. Extract inputs @@ -6,15 +6,17 @@ def analyze_sample(request: AnalysisRequest) -> AnalysisResponse: raw_value = request.inputs.get("Growth_Metric") if raw_value is None: - return AnalysisResponse(result=0.0, outcome=Outcome.FAILURE) + return AnalysisResponse(objectives=[], outcome=Outcome.FAILURE, error_string="No raw value provided, cannot analyze") # 2. Perform Logic print(f"Analyzing sample with value: {raw_value}") - calculated_score = raw_value * 1.5 + calculated_score = raw_value * 1.5 + + objective_score = Objective("Calculated Score", calculated_score) # 3. Return Result - return AnalysisResponse(result=calculated_score, outcome=Outcome.SUCCESS) + return AnalysisResponse(objectives=[objective_score], outcome=Outcome.SUCCESS) if __name__ == "__main__": service = AresAnalyzerService( diff --git a/PyAres/Demo/Analyzers/ax_analyzer_test.py b/PyAres/Demo/Analyzers/ax_analyzer_test.py new file mode 100644 index 0000000..0c630b3 --- /dev/null +++ b/PyAres/Demo/Analyzers/ax_analyzer_test.py @@ -0,0 +1,80 @@ +from PyAres import * +import math +import random + +def analyze(request: AnalysisRequest) -> AnalysisResponse: + """ + Evaluates the parameters chosen by your AX Planner and returns a simulated yield. + """ + + try: + # 1. Extract the values ARES OS logged for this specific iteration. + # Ensure the parameter names in your ARES Campaign match these strings exactly. + temperature = request.inputs["Temperature"] + concentration = request.inputs["Concentration"] + + # 2. Define the "Hidden Ground Truth" + # This is the goal your AX Planner is trying to discover. + ideal_temp = 165.0 + ideal_conc = 3.2 + max_yield = 100.0 + + # 3. Calculate the distance penalty (the variances control the "width" of the peak) + temp_variance = 400.0 + conc_variance = 2.0 + + distance_penalty = (((temperature - ideal_temp) ** 2) / temp_variance) + \ + (((concentration - ideal_conc) ** 2) / conc_variance) + + # 4. Calculate theoretical yield and add noise + simulated_yield = max_yield * math.exp(-distance_penalty) + noise = random.gauss(0, 1.5) # mean=0, std_dev=1.5 + + # Clamp the final yield between 0 and 100% + final_yield = max(0.0, min(100.0, simulated_yield + noise)) + + print(f"[Demo Analyzer] Received T={temperature:.1f}, C={concentration:.1f} | Calculated Yield: {final_yield:.2f}%") + + # Preferred new usage: return an objective-based AnalysisResponse + return AnalysisResponse( + objectives=[ + Objective( + objective_name="yield", + objective_value=final_yield, + objective_metadata={"units": "%"} + ) + ] + ) + + except Exception as e: + print(f"[Demo Analyzer] Error during analysis: {e}") + # If extraction fails (e.g., missing parameter names), return a terrible score so the planner learns to avoid it + return AnalysisResponse( + objectives=[ + Objective( + objective_name="yield", + objective_value=None, + objective_metadata={"error": str(e)} + ) + ], + outcome=Outcome.FAILURE, + error_string=str(e), + ) + + + +if __name__ == "__main__": + # Initialize the Analyzer Service + demo_analyzer = AresAnalyzerService(custom_analysis_logic=analyze, + name="Simulated Yield Demo", + version="1.0.0", + description="Calculates a simulated material yield based on a hidden ideal Temperature (165) and Concentration (3.2).", + port=8200) + + demo_analyzer.add_analysis_parameter("Temperature", AresDataType.NUMBER) + demo_analyzer.add_analysis_parameter("Concentration", AresDataType.NUMBER) + + demo_analyzer.add_objective_output("yield", AresDataType.NUMBER, "A numeric value that indicates the experiments yielded result") + + print("Starting PyAres Simulated Goal Analyzer...") + demo_analyzer.start() \ No newline at end of file diff --git a/PyAres/Demo/Planners/planner_test.py b/PyAres/Demo/Planners/planner_test.py index 8939124..ff115c3 100644 --- a/PyAres/Demo/Planners/planner_test.py +++ b/PyAres/Demo/Planners/planner_test.py @@ -8,12 +8,12 @@ def plan(request: PlanRequest) -> PlanResponse: gpdoods = [] names = [] - for i in range(len(request.analysis_results)): - currentAnalysis = request.analysis_results[i] + for objective_set in request.analysis_objectives: + print(f"Received a total of {len(request.analysis_objectives)} objective sets") - for j in range(len(currentAnalysis.objectives)): - currentObjective : Objective = currentAnalysis.objectives[j] - print(f"Analysis Result {i}: Objective number {j} is named {currentObjective.objective_name} and has a value of {currentObjective.objective_value}") + for objective in objective_set: + print(f"Objective Name: {objective.objective_name}") + print(f"Objective Value: {objective.objective_value}") for param in request.parameters: diff --git a/PyAres/Planning/planner_models.py b/PyAres/Planning/planner_models.py index 3c13433..e3577a4 100644 --- a/PyAres/Planning/planner_models.py +++ b/PyAres/Planning/planner_models.py @@ -1,6 +1,7 @@ from typing import Dict, Any, List, Sequence, Optional from ..Models import Outcome, AresDataType, RequestMetadata, PlanStatusCode from enum import Enum +from ..Analyzing.analyzer_models import Objective class ParameterHistoryItem: """ Represents a single historical parameter item """ @@ -95,24 +96,33 @@ def achieved_values(self) -> list: @property def bounds(self) -> list: return [self.minimum_value, self.maximum_value] - -class ParamHistoryInfo: +class AnalysisDataEntry: """ - Represents the history of a given parameter. + Represents the analysis data for a single experiment in a planning batch. - Designed to provide a more user-friendly abstraction for interacting with a param history object. + Each entry currently exposes a list of analysis objectives as native Python objects. """ - def __init__(self, planned_value: Any, achieved_value: Any): + def __init__(self, analysis_objectives: List[Objective]): """ - Initializes a ParamHistoryInfo. + Initializes an AnalysisDataEntry. Args: - planned_value (Any): The value given directly from the planner. - achieved_value (Any): An optional value that represents the real world achieved value, which may differ from the planners target value. + analysis_objectives: A list of Objective instances produced by the analyzer. """ - self.planned_value = planned_value - self.achieved_value = achieved_value + self.analysis_objectives = analysis_objectives + + def __str__(self) -> str: + if not self.analysis_objectives: + return "AnalysisDataEntry(objectives: (none))" + objectives_str = ", ".join( + f"{obj.objective_name}={obj.objective_value}" + for obj in self.analysis_objectives + ) + return f"AnalysisDataEntry(objectives: [{objectives_str}])" + + def __repr__(self) -> str: + return self.__str__() class PlanRequest: @@ -127,16 +137,23 @@ def __init__(self, analysis_results: Sequence[float], metadata: RequestMetadata = RequestMetadata.from_default_values(), batch_size: int = 1, - previous_plan_status_codes: List[PlanStatusCode] = None): + previous_plan_status_codes: List[PlanStatusCode] = None, + analysis_data: Optional[List[AnalysisDataEntry]] = None): """ Initializes a PlanRequest. Args: parameters: A list of PlanningParameter objects. + settings: A dictionary of adapter settings associated with this request. + analysis_results: A deprecated sequence of numeric analysis results. This will be removed in a future major release. + metadata: Additional request metadata from ARES. + batch_size: The number of plans requested from this planner. + previous_plan_status_codes: A list of status codes associated with previously planned experiments. + analysis_data: A list of AnalysisDataEntry objects, one per experiment, containing analyzer-produced objectives. """ self.parameters = parameters self.settings = settings - self.analysis_results = analysis_results + self._analysis_results = list(analysis_results) self.batch_size = batch_size self.request_metadata = metadata @@ -145,10 +162,13 @@ def __init__(self, else: self.previous_plan_status_codes = previous_plan_status_codes + self.analysis_data: List[AnalysisDataEntry] = analysis_data or [] + def __str__(self) -> str: param_str = "\n ".join(self.parameter_names) settings_str = "\n ".join([f"{k}: {v}" for k, v in self.settings.items()]) - analysis_str = "\n ".join([f"{i}: {val}" for i, val in enumerate(self.analysis_results)]) + analysis_results_str = "\n ".join([f"{i}: {val}" for i, val in enumerate(self._analysis_results)]) + analysis_data_str = "\n ".join([f"{i}: {val}" for i, val in enumerate(self.analysis_data)]) metadata_str = str(self.request_metadata).replace('\n', '\n ') return (f"PlanRequest object with:\n" @@ -157,7 +177,9 @@ def __str__(self) -> str: f"settings:\n" f"{settings_str}\n" f"analysis_results:\n" - f"{analysis_str}\n" + f"{analysis_results_str}\n" + f"analysis_data:\n" + f"{analysis_data_str}\n" f"request_metadata:\n" f"{metadata_str}" f"batch_size:\n" @@ -180,16 +202,46 @@ def __getattr__(self, name): @property def parameter_names(self) -> list[str]: return [p.name for p in self.parameters] + @property - def planned_parameter_table(self) ->list: + def planned_parameter_table(self) -> list: return [p.planned_values for p in self.parameters] + @property - def acheived_parameter_table(self) ->list: + def acheived_parameter_table(self) -> list: return [p.achieved_values for p in self.parameters] + @property + def analysis_results(self) -> list[float]: + """ + Deprecated numeric analysis results. + + Accessing this property will emit a DeprecationWarning. Use `analysis_data` + (and its contained objectives) instead. + """ + import warnings + warnings.warn( + "PlanRequest.analysis_results is deprecated and will be removed in a future major release. " + "Use PlanRequest.analysis_data instead.", + DeprecationWarning, + stacklevel=2, + ) + return self._analysis_results + + @analysis_results.setter + def analysis_results(self, value: Sequence[float]): + self._analysis_results = list(value) + + @property + def analysis_objectives(self) -> List[List[Objective]]: + """ + Convenience property that returns a list of objective lists, one per experiment. + """ + return [entry.analysis_objectives for entry in self.analysis_data] + class PlanResponse: - """ Represents a PlanResponse message to be send to ARES. """ + """ Represents a PlanResponse message to be sent to ARES. """ def __init__(self, parameter_names: Optional[list[str]] = None, parameter_values: Optional[list] = None, diff --git a/PyAres/Planning/planning_service.py b/PyAres/Planning/planning_service.py index aaf7feb..9780807 100644 --- a/PyAres/Planning/planning_service.py +++ b/PyAres/Planning/planning_service.py @@ -1,33 +1,26 @@ import grpc -import inspect -import asyncio -from concurrent import futures from typing import Callable, Awaitable, Union, Dict from ares_datamodel.planning.remote import ares_remote_planner_service_pb2_grpc as planner_service_grpc from ares_datamodel.planning import planner_pb2 from ares_datamodel.planning import planner_service_capabilities_pb2 from ares_datamodel.planning import plan_pb2 -from ares_datamodel import ares_data_schema_pb2 from ares_datamodel import ares_data_type_pb2 from ares_datamodel import ares_outcome_enum_pb2 -from ares_datamodel.connection import connection_state_pb2 -from ares_datamodel.connection import connection_info_pb2 from ares_datamodel import ares_struct_pb2 # Import Utilities from ..Utils import ares_value_utils -from ..Utils import ares_data_schema_utils from ..Utils import ares_data_type_utils from ..Utils import ares_struct_utils from ..Utils import ares_plan_status_code_utils from ..Utils import plan_response_utils from ..Utils.ares_service_base import AresServiceWrapperBase, AresBaseService -from ..Utils.logging_utils import setup_logger # Import python models -from ..Models import ares_data_models, Limits +from ..Models import ares_data_models from .planner_models import * +from ..Analyzing.analyzer_models import Objective # Type hint for the user's custom planning logic PlanLogicFunction = Callable[[PlanRequest], Union[PlanResponse, Awaitable[PlanResponse], List[Plan], Awaitable[List[Plan]]]] @@ -36,12 +29,13 @@ class AresPlannerServiceWrapper(AresServiceWrapperBase, planner_service_grpc.Are """ A wrapper around the gRPC service to expose native Python objects for planning """ - def __init__(self, service_name: str, version: str, description: str, timeout: int, custom_plan_logic: PlanLogicFunction): + def __init__(self, service_name: str, version: str, description: str, timeout: int, custom_plan_logic: PlanLogicFunction, multi_objective_capable: bool = False): super().__init__(service_name, version, description, timeout) self._custom_plan_logic: PlanLogicFunction = custom_plan_logic self._current_settings: Dict[str, ares_struct_pb2.AresValue] = {} self._planner_options: list[planner_pb2.Planner] = [] self._supported_types: list[ares_data_type_pb2.AresDataType] = [] + self._multi_objective_capable: bool = multi_objective_capable def GetPlannerServiceCapabilities(self, request, context) -> planner_service_capabilities_pb2.PlannerServiceCapabilities: print("Capabilities Requested!") @@ -49,6 +43,7 @@ def GetPlannerServiceCapabilities(self, request, context) -> planner_service_cap capabilities.service_name = self._service_name capabilities.accepted_types.extend(self._supported_types) capabilities.available_planners.extend(self._planner_options) + capabilities.multi_objective_capable = self._multi_objective_capable for(key, value) in self._settings.items(): capabilities.settings_schema.fields[key].CopyFrom(value) @@ -57,6 +52,35 @@ def GetPlannerServiceCapabilities(self, request, context) -> planner_service_cap return capabilities + def _proto_analysis_data_to_python(self, proto_analysis_data_list) -> list[AnalysisDataEntry]: + """ + Convert a sequence of proto AnalysisData messages into native AnalysisDataEntry objects. + + Each AnalysisDataEntry contains a list of native Objective instances, fully decoupled from the proto layer. + """ + analysis_entries: list[AnalysisDataEntry] = [] + + for proto_entry in proto_analysis_data_list: + objectives: list[Objective] = [] + + # Each proto_entry.analysis_objectives contains analyzing.Objective messages + for proto_obj in proto_entry.analysis_objectives: + objective_metadata = ares_struct_utils.ares_struct_to_dict( + proto_obj.objective_metadata + ) if hasattr(proto_obj, "objective_metadata") else {} + + objectives.append( + Objective( + objective_name=proto_obj.objective_name, + objective_value=ares_value_utils.ares_value_to_py(proto_obj.objective_value), + objective_metadata=objective_metadata, + ) + ) + + analysis_entries.append(AnalysisDataEntry(analysis_objectives=objectives)) + + return analysis_entries + def Plan(self, request: plan_pb2.PlanningRequest, context) -> plan_pb2.PlanningResponse: """ Implements the gRPC Plan method. This method converts protobuf requests to native Python objects @@ -78,12 +102,18 @@ def Plan(self, request: plan_pb2.PlanningRequest, context) -> plan_pb2.PlanningR initial_value=ares_value_utils.ares_value_to_py(proto_param.initial_value) )) - python_request = PlanRequest(parameters=parameters, - settings=ares_struct_utils.ares_struct_to_dict(request.adapter_settings), - analysis_results=list(request.analysis_results), - metadata=RequestMetadata(request.metadata), - batch_size=request.batch_size, - previous_plan_status_codes=[ares_plan_status_code_utils.proto_plan_status_to_python_plan_status(c) for c in request.previous_plan_status_codes]) + python_request = PlanRequest( + parameters=parameters, + settings=ares_struct_utils.ares_struct_to_dict(request.adapter_settings), + analysis_results=list(request.analysis_results), + metadata=RequestMetadata(request.metadata), + batch_size=request.batch_size, + previous_plan_status_codes=[ + ares_plan_status_code_utils.proto_plan_status_to_python_plan_status(c) + for c in request.previous_plan_status_codes + ], + analysis_data=self._proto_analysis_data_to_python(request.analysis_data), + ) #Handle call using the user's custom planning logic response_proto = plan_pb2.PlanningResponse() @@ -134,9 +164,21 @@ def __init__(self, custom_plan_logic: PlanLogicFunction, timeout: int = 30, use_localhost: bool = True, port: int = 7082, - max_message_size: int = -1): + max_message_size: int = -1, + multi_objective_capable: bool = False): """ Initializes the AresPlannerService + + Args: + custom_plan_logic: The method that should be called whenever your planner is asked to provide planned values. + service_name: The friendly name to be associated with your planning service. + service_description: A brief description of your planning service and it's capabilities. + service_version: A version that is associated with your planner service. + timeout: A timeout value that determines how long ARES will wait, in seconds, expecting a response from your planner. Defaults to 30 seconds. + use_localhost: If set to true, hosts the planner service via localhost. Setting to false will host the planner on your network, useful if you need remote access. Defaults to true. + port: The port your planner will use for communications. Defaults to 7082. + max_message_size: The max size, in megabytes, of the messages your service will be able to handle. Defaults to -1, meaning your service will use the protobuf default of 4MB. + multi_objective_capable: A boolean value that tells ARES whether your planner is capable of planning over multiple analysis objective values, defaults to False. """ super().__init__( service_name=service_name, @@ -150,7 +192,7 @@ def __init__(self, custom_plan_logic: PlanLogicFunction, # For backwards compatibility with anyone accessing service_description directly self.service_description = service_description - self._service_wrapper = AresPlannerServiceWrapper(service_name, service_version, service_description, timeout, custom_plan_logic) + self._service_wrapper = AresPlannerServiceWrapper(service_name, service_version, service_description, timeout, custom_plan_logic, multi_objective_capable) planner_service_grpc.add_AresRemotePlannerServiceServicer_to_server(self._service_wrapper, self.get_server()) def add_planner_option(self, planner_name: str, planner_description: str, planner_version: str): @@ -163,4 +205,4 @@ def add_supported_type(self, type: ares_data_models.AresDataType): """ Adds the specified type to the list of value types your planenr service accepts. """ - self._service_wrapper._supported_types.append(ares_data_type_utils.python_ares_type_to_proto_ares_type(type)) + self._service_wrapper._supported_types.append(ares_data_type_utils.python_ares_type_to_proto_ares_type(type)) \ No newline at end of file diff --git a/PyAres/__init__.py b/PyAres/__init__.py index 487ff3b..8b46419 100644 --- a/PyAres/__init__.py +++ b/PyAres/__init__.py @@ -10,6 +10,7 @@ from .Analyzing import AnalysisResponse from .Analyzing import AnalysisRequest from .Analyzing import InfoResponse +from .Analyzing import Objective from .Device import AresDeviceService from .Device import DeviceCommandDescriptor from .Device import DeviceSchemaEntry diff --git a/pyproject.toml b/pyproject.toml index 1c22acb..6f97083 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ ] dependencies = [ - "ares-datamodel >= 0.31.0", + "ares-datamodel >= 0.36.0", ] [project.urls] diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index 75ad355..61a4329 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -1,7 +1,7 @@ import unittest from PyAres import AresAnalyzerService, Outcome from PyAres.Models import ares_data_models -from PyAres.Analyzing.analyzer_models import AnalysisRequest, AnalysisResponse +from PyAres.Analyzing.analyzer_models import AnalysisRequest, AnalysisResponse, Objective from ares_datamodel.analyzing.remote import ares_remote_analyzer_service_pb2 as analyzer_service from ares_datamodel.analyzing import analysis_pb2 from ares_datamodel import ares_data_type_pb2, ares_outcome_enum_pb2, ares_data_schema_pb2 @@ -148,9 +148,13 @@ def test_execution_logic(self): self.assertEqual(request.inputs["Voltage"], 5.5) self.assertEqual(request.settings["Mode"], "Fast") self.assertEqual(request.request_metadata.experiment_id, "EXP-001") - self.assertIsInstance(response, analysis_pb2.Analysis) + self.assertIsInstance(response, analysis_pb2.AnalysisResponse) self.assertEqual(response.analysis_outcome, ares_outcome_enum_pb2.SUCCESS) - self.assertEqual(response.result, 100.0) + self.assertEqual(len(response.objectives), 1) + + objective = response.objectives[0] + self.assertEqual(objective.objective_name, "result") + self.assertEqual(objective.objective_value.number_value, 100.0) def test_error_handling(self): """Test that user exceptions are caught gracefully.""" @@ -217,8 +221,131 @@ async def async_analyze(request): # This will likely fail currently due to the bug identified in analysis_service.py response = self.service._service_wrapper.Analyze(mock_request, None) - self.assertEqual(response.result, 200.0) + self.assertEqual(len(response.objectives), 1) self.assertEqual(response.analysis_outcome, ares_outcome_enum_pb2.SUCCESS) + + objective = response.objectives[0] + self.assertEqual(objective.objective_name, "result") + self.assertEqual(objective.objective_value.number_value, 200.0) + + + def test_analyze_explicit_objectives_and_metadata(self): + """Test that explicit objectives and metadata are converted correctly.""" + + def objective_analyze(request: AnalysisRequest) -> AnalysisResponse: + return AnalysisResponse( + outcome=Outcome.SUCCESS, + error_string="", + objectives=[ + Objective( + objective_name="score", + objective_value=42.0, + objective_metadata={"tag": "primary", "run": 1}, + ), + Objective( + objective_name="label", + objective_value="PASS", + objective_metadata={}, + ), + ], + ) + + self.service = AresAnalyzerService( + objective_analyze, + self.analyzer_name, + self.analyzer_version, + port=0, + ) + + mock_request = analyzer_service.AnalysisRequest() + + ares_struct_utils.dict_to_ares_struct({"Voltage": 5.5}, mock_request.inputs) + ares_struct_utils.dict_to_ares_struct({"Mode": "Fast"}, mock_request.settings) + + response = self.service._service_wrapper.Analyze(mock_request, MockGrpcContext()) + + self.assertIsInstance(response, analysis_pb2.AnalysisResponse) + self.assertEqual(response.analysis_outcome, ares_outcome_enum_pb2.SUCCESS) + self.assertEqual(len(response.objectives), 2) + + score_obj = response.objectives[0] + self.assertEqual(score_obj.objective_name, "score") + self.assertEqual(score_obj.objective_value.number_value, 42.0) + self.assertIn("tag", score_obj.objective_metadata.fields) + self.assertIn("run", score_obj.objective_metadata.fields) + self.assertEqual(score_obj.objective_metadata.fields["tag"].string_value, "primary") + self.assertEqual(score_obj.objective_metadata.fields["run"].number_value, 1) + + label_obj = response.objectives[1] + self.assertEqual(label_obj.objective_name, "label") + self.assertEqual(label_obj.objective_value.string_value, "PASS") + self.assertEqual(len(label_obj.objective_metadata.fields), 0) + + def test_analyze_invalid_return_type(self): + """Test that an invalid return type yields a FAILURE response.""" + + def bad_analyze(request: AnalysisRequest): + return None + + self.service = AresAnalyzerService( + bad_analyze, "BadAnalyzer", "1.0", port=0 + ) + + mock_context = MockGrpcContext() + mock_request = analyzer_service.AnalysisRequest() + + response = self.service._service_wrapper.Analyze(mock_request, mock_context) + + self.assertIsInstance(response, analysis_pb2.AnalysisResponse) + self.assertEqual(response.analysis_outcome, ares_outcome_enum_pb2.FAILURE) + self.assertIn("invalid type", response.error_string) + + def test_analyze_failure_outcome_and_error_string(self): + """Test that failure outcome and error_string propagate correctly.""" + + def failure_analyze(request: AnalysisRequest) -> AnalysisResponse: + return AnalysisResponse( + outcome=Outcome.FAILURE, + error_string="Domain-specific failure", + objectives=[], + ) + + self.service = AresAnalyzerService( + failure_analyze, "FailureAnalyzer", "1.0", port=0 + ) + + mock_request = analyzer_service.AnalysisRequest() + response = self.service._service_wrapper.Analyze( + mock_request, MockGrpcContext() + ) + + self.assertIsInstance(response, analysis_pb2.AnalysisResponse) + self.assertEqual(response.analysis_outcome, ares_outcome_enum_pb2.FAILURE) + self.assertEqual(response.error_string, "Domain-specific failure") + self.assertEqual(len(response.objectives), 0) + + def test_deprecated_result_usage_still_working(self): + """Test that deprecated result usage still produces a 'result' objective.""" + + def deprecated_analyze(request: AnalysisRequest) -> AnalysisResponse: + return AnalysisResponse(result=123.0, outcome=Outcome.SUCCESS) + + self.service = AresAnalyzerService( + deprecated_analyze, "DeprecatedAnalyzer", "1.0", port=0 + ) + + mock_request = analyzer_service.AnalysisRequest() + response = self.service._service_wrapper.Analyze( + mock_request, MockGrpcContext() + ) + + self.assertIsInstance(response, analysis_pb2.AnalysisResponse) + self.assertEqual(response.analysis_outcome, ares_outcome_enum_pb2.SUCCESS) + self.assertEqual(len(response.objectives), 1) + + objective = response.objectives[0] + self.assertEqual(objective.objective_name, "result") + self.assertEqual(objective.objective_value.number_value, 123.0) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/tests/test_analyzer_integration.py b/tests/test_analyzer_integration.py index 086774d..d821753 100644 --- a/tests/test_analyzer_integration.py +++ b/tests/test_analyzer_integration.py @@ -67,7 +67,10 @@ def test_end_to_end_analysis(self): response = self.stub.Analyze(request) # Verify results - self.assertEqual(response.result, 20.0) + self.assertEqual(len(response.objectives), 1) + objective = response.objectives[0] + + self.assertEqual(objective.objective_value.number_value, 20.0) self.assertEqual(response.analysis_outcome, 1) # SUCCESS def test_remote_capabilities(self): diff --git a/tests/test_planner_analysis_data.py b/tests/test_planner_analysis_data.py new file mode 100644 index 0000000..54240c2 --- /dev/null +++ b/tests/test_planner_analysis_data.py @@ -0,0 +1,182 @@ +import warnings +from typing import List + +import pytest + +from PyAres.Planning.planner_models import ( + PlanRequest, + PlanningParameter, + ParameterHistoryItem, + AnalysisDataEntry, +) +from PyAres.Models import RequestMetadata, AresDataType +from PyAres.Planning.planning_service import AresPlannerServiceWrapper +from tests.mock_grpc_context import MockGrpcContext +from ares_datamodel.planning import plan_pb2 +from ares_datamodel import ares_struct_pb2 + + +def _make_dummy_parameter(name: str) -> PlanningParameter: + return PlanningParameter( + name=name, + data_type=AresDataType.ARES_DATA_TYPE_DOUBLE if hasattr(AresDataType, "ARES_DATA_TYPE_DOUBLE") else AresDataType(0), + minimum_value=0.0, + maximum_value=1.0, + param_history=[ParameterHistoryItem(0.1, 0.1)], + is_planned=True, + is_result=False, + planner_name="test_planner", + initial_value=0.0, + ) + + +def test_plan_request_analysis_data_native_and_no_proto_leakage(): + """ + Verify that PlanRequest stores analysis_data as native AnalysisDataEntry objects + containing native Objective instances, and does not expose proto messages. + """ + # Build dummy objectives (native Objective from analyzer_models) + from PyAres.Analyzing.analyzer_models import Objective + + obj1 = Objective("obj1", 1.23, {"units": "unit1"}) + obj2 = Objective("obj2", 4.56, {"units": "unit2"}) + + entry1 = AnalysisDataEntry(analysis_objectives=[obj1]) + entry2 = AnalysisDataEntry(analysis_objectives=[obj2]) + + params: List[PlanningParameter] = [_make_dummy_parameter("p1")] + + req = PlanRequest( + parameters=params, + settings={"adapter": "settings"}, + analysis_results=[0.5, 0.6], + metadata=RequestMetadata.from_default_values(), + batch_size=2, + previous_plan_status_codes=[], + analysis_data=[entry1, entry2], + ) + + # Ensure we got the entries we expect + assert len(req.analysis_data) == 2 + assert isinstance(req.analysis_data[0], AnalysisDataEntry) + assert isinstance(req.analysis_data[1], AnalysisDataEntry) + + # Ensure objectives are native Objective instances + first_objectives = req.analysis_data[0].analysis_objectives + second_objectives = req.analysis_data[1].analysis_objectives + + assert len(first_objectives) == 1 + assert len(second_objectives) == 1 + + assert isinstance(first_objectives[0], Objective) + assert isinstance(second_objectives[0], Objective) + + all_objectives = req.analysis_objectives + assert len(all_objectives) == 2 + assert isinstance(all_objectives[0][0], Objective) + assert isinstance(all_objectives[1][0], Objective) + + # String representation should include analysis_data and not raise warnings + with warnings.catch_warnings(record=True) as w: + s = str(req) + assert "analysis_data:" in s + assert not any(issubclass(wi.category, DeprecationWarning) for wi in w) + + +def test_plan_request_analysis_results_deprecation_on_access_only(): + """ + Verify that accessing PlanRequest.analysis_results emits a DeprecationWarning, + but the internal storage remains correct and printing does not emit the warning. + """ + params: List[PlanningParameter] = [_make_dummy_parameter("p1")] + req = PlanRequest( + parameters=params, + settings={}, + analysis_results=[1.0, 2.0], + metadata=RequestMetadata.from_default_values(), + batch_size=1, + previous_plan_status_codes=[], + analysis_data=[], + ) + + # Internal storage is as expected + assert req._analysis_results == [1.0, 2.0] + + # Accessing the property should emit a DeprecationWarning + with pytest.warns(DeprecationWarning): + vals = req.analysis_results + assert vals == [1.0, 2.0] + + # Calling __str__ should not emit DeprecationWarning + with warnings.catch_warnings(record=True) as w: + s = str(req) + assert "analysis_results:" in s + assert not any(issubclass(wi.category, DeprecationWarning) for wi in w) + + +def test_planner_service_wrapper_maps_proto_analysis_data_to_native(monkeypatch): + """ + Integration-style test that verifies AresPlannerServiceWrapper.Plan converts + proto AnalysisData messages into native AnalysisDataEntry with native Objective + instances, and passes them into the PlanRequest. + """ + from PyAres.Analyzing.analyzer_models import Objective + + captured_request = {"req": None} + + def custom_logic(req: PlanRequest): + captured_request["req"] = req + # Return an empty list of plans to keep response handling simple + return [] + + wrapper = AresPlannerServiceWrapper( + service_name="test_service", + version="1.0.0", + description="Test planner service", + timeout=30, + custom_plan_logic=custom_logic, + ) + + planning_request = plan_pb2.PlanningRequest() + planning_request.adapter_settings.CopyFrom(ares_struct_pb2.AresStruct()) + planning_request.analysis_results.extend([0.1, 0.2]) + planning_request.metadata.system_name = "test_service" + planning_request.batch_size = 1 + proto_param = planning_request.planning_parameters.add() + proto_param.parameter_name = "p1" + proto_param.maximum_value = 1.0 + proto_param.minimum_value = 0.0 + proto_param.is_planned = True + proto_param.is_result = False + proto_param.planner_name = "test_planner" + + # Add default data type + if hasattr(proto_param, "data_type"): + proto_param.data_type = 0 + + proto_param.initial_value.CopyFrom(ares_struct_pb2.AresValue(float_value=0.0)) + + analysis_data_entry = planning_request.analysis_data.add() + + proto_obj = analysis_data_entry.analysis_objectives.add() + proto_obj.objective_name = "proto_obj" + proto_obj.objective_value.CopyFrom(ares_struct_pb2.AresValue(float_value=3.14)) + proto_obj.objective_metadata.CopyFrom(ares_struct_pb2.AresStruct()) + + # Call Plan with a mock gRPC context + context = MockGrpcContext() + _ = wrapper.Plan(planning_request, context) + + # Verify the captured PlanRequest + req = captured_request["req"] + assert req is not None + + # Ensure analysis_data contains native AnalysisDataEntry objects + assert len(req.analysis_data) == 1 + assert isinstance(req.analysis_data[0], AnalysisDataEntry) + + # Ensure objectives inside are native Objective instances + objectives = req.analysis_data[0].analysis_objectives + assert len(objectives) == 1 + assert isinstance(objectives[0], Objective) + assert objectives[0].objective_name == "proto_obj" \ No newline at end of file