Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PyAres/Analyzing/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
]
69 changes: 53 additions & 16 deletions PyAres/Analyzing/analysis_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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)
110 changes: 100 additions & 10 deletions PyAres/Analyzing/analyzer_models.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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}")

Expand Down
10 changes: 6 additions & 4 deletions PyAres/Demo/Analyzers/analyzer_wiki.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
from PyAres import AresAnalyzerService, AnalysisRequest, AnalysisResponse, AresDataType, Outcome, Limits
from PyAres import *

def analyze_sample(request: AnalysisRequest) -> AnalysisResponse:
# 1. Extract inputs
# 'Growth_Metric' would come from a sensor or previous step
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(
Expand Down
80 changes: 80 additions & 0 deletions PyAres/Demo/Analyzers/ax_analyzer_test.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 5 additions & 5 deletions PyAres/Demo/Planners/planner_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading