# BSD-3-Clause License # # Copyright 2017 Orange # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # 3. Neither the name of the copyright holder nor the names of its contributors # may be used to endorse or promote products derived from this software # without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import pathlib from collections import defaultdict from collections import Iterable as CollectionIterable from typing import Dict, Iterable, Union, List import yaml from pydcop.dcop.objects import ( VariableDomain, Variable, ExternalVariable, VariableWithCostFunc, VariableNoisyCostFunc, AgentDef, ) from pydcop.dcop.scenario import EventAction, DcopEvent, Scenario from pydcop.dcop.dcop import DCOP from pydcop.dcop.relations import ( relation_from_str, RelationProtocol, NAryMatrixRelation, assignment_matrix, generate_assignment_as_dict, constraint_from_str, constraint_from_external_definition, ) from pydcop.utils.expressionfunction import ExpressionFunction from pydcop.distribution.objects import DistributionHints class DcopInvalidFormatError(Exception): pass def load_dcop_from_file(filenames: Union[str, Iterable[str]]): """ load a dcop from one or several files Parameters ---------- filenames: str or iterable of str The dcop can the given as a single file or as several files. When passing an iterable of file names, their content is concatenated before parsing. This can be usefull when you want to define the agents in a separate file. Returns ------- A DCOP object built by parsing the files """ content = "" main_dir = None if not isinstance(filenames, CollectionIterable): filenames = [filenames] for filename in filenames: p = pathlib.Path(filename) if main_dir is None: main_dir = p.parent content += p.read_text(encoding="utf-8") if content: return load_dcop(content, main_dir) def load_dcop(dcop_str: str, main_dir=None) -> DCOP: loaded = yaml.load(dcop_str, Loader=yaml.FullLoader) if "name" not in loaded: raise ValueError("Missing name in dcop string") if "objective" not in loaded or loaded["objective"] not in ["min", "max"]: raise ValueError("Objective is mandatory and must be min or max") dcop = DCOP( loaded["name"], loaded["objective"], loaded["description"] if "description" in loaded else "", ) dcop.domains = _build_domains(loaded) dcop.variables = _build_variables(loaded, dcop) dcop.external_variables = _build_external_variables(loaded, dcop) dcop._constraints = _build_constraints(loaded, dcop, main_dir) dcop._agents_def = _build_agents(loaded) dcop.dist_hints = _build_dist_hints(loaded, dcop) return dcop def dcop_yaml(dcop: DCOP) -> str: dcop_dict = {"name": dcop.name, "objective": dcop.objective} dcop_str = yaml.dump(dcop_dict, default_flow_style=False) dcop_str += "\n" dcop_str += _yaml_domains(dcop.domains.values()) dcop_str += "\n" dcop_str += _yaml_variables(dcop.variables.values()) dcop_str += "\n" dcop_str += _yaml_constraints(dcop.constraints.values()) dcop_str += "\n" dcop_str += yaml_agents(dcop.agents.values()) return dcop_str def _yaml_domains(domains): d_dict = {} for domain in domains: d_dict[domain.name] = {"values": list(domain.values), "type": domain.type} return yaml.dump({"domains": d_dict}) # , default_flow_style=False) def _build_domains(loaded) -> Dict[str, VariableDomain]: domains = {} if "domains" in loaded: for d_name in loaded["domains"]: d = loaded["domains"][d_name] values = d["values"] if len(values) == 1 and ".." in values[0]: values = str_2_domain_values(d["values"][0]) d_type = d["type"] if "type" in d else "" domains[d_name] = VariableDomain(d_name, d_type, values) return domains def _yaml_variables(variables): var_dict = {} for v in variables: var_dict[v.name] = {"domain": v.domain.name} if v.initial_value is not None: var_dict[v.name]["initial_value"] = v.initial_value return yaml.dump({"variables": var_dict}, default_flow_style=False) def _build_variables(loaded, dcop) -> Dict[str, Variable]: variables = {} if "variables" in loaded: for v_name in loaded["variables"]: v = loaded["variables"][v_name] domain = dcop.domain(v["domain"]) initial_value = v["initial_value"] if "initial_value" in v else None if initial_value and initial_value not in domain.values: raise ValueError( "initial value {} is not in the domain {} " "of the variable {}".format(initial_value, domain.name, v_name) ) if "cost_function" in v: cost_expression = v["cost_function"] cost_func = ExpressionFunction(cost_expression) if "noise_level" in v: variables[v_name] = VariableNoisyCostFunc( v_name, domain, cost_func, initial_value, noise_level=v["noise_level"], ) else: variables[v_name] = VariableWithCostFunc( v_name, domain, cost_func, initial_value ) else: variables[v_name] = Variable(v_name, domain, initial_value) return variables def _build_external_variables(loaded, dcop) -> Dict[str, ExternalVariable]: ext_vars = {} if "external_variables" in loaded: for v_name in loaded["external_variables"]: v = loaded["external_variables"][v_name] domain = dcop.domain(v["domain"]) initial_value = v["initial_value"] if "initial_value" in v else None if initial_value and initial_value not in domain.values: raise ValueError( "initial value {} is not in the domain {} " "of the variable {}".format(initial_value, domain.name, v_name) ) ext_vars[v_name] = ExternalVariable(v_name, domain, initial_value) return ext_vars def _build_constraints(loaded, dcop, main_dir) -> Dict[str, RelationProtocol]: constraints = {} if "constraints" in loaded: for c_name in loaded["constraints"]: c = loaded["constraints"][c_name] if "type" not in c: raise ValueError( "Error in contraints {} definition: type is " 'mandatory and only "intention" is ' "supported for now".format(c_name) ) elif c["type"] == "intention": if "source" in c: src_path = c["source"] \ if pathlib.Path(c["source"]).is_absolute() \ else main_dir / c["source"] constraints[c_name] = constraint_from_external_definition( c_name, src_path, c["function"], dcop.all_variables ) else: constraints[c_name] = constraint_from_str( c_name, c["function"], dcop.all_variables ) elif c["type"] == "extensional": values_def = c["values"] default = None if "default" not in c else c["default"] if type(c["variables"]) != list: # specific case for constraint with a single variable v = dcop.variable(c["variables"].strip()) values = [default] * len(v.domain) for value, assignments_def in values_def.items(): if isinstance(assignments_def, str): for ass_def in assignments_def.split("|"): iv, _ = v.domain.to_domain_value(ass_def.strip()) values[iv] = value else: values[v.domain.index(assignments_def)] = value constraints[c_name] = NAryMatrixRelation([v], values, name=c_name) continue # For constraints that depends on several variables vars = [dcop.variable(v) for v in c["variables"]] values = assignment_matrix(vars, default) for value, assignments_def in values_def.items(): # can be a str like "1 2 3" or "1 2 3 | 1 3 4" # several assignment for the same value are separated with | assignments_def = assignments_def.split("|") for ass_def in assignments_def: val_position = values vals_def = ass_def.split() for i, val_def in enumerate(vals_def[:-1]): iv, _ = vars[i].domain.to_domain_value(val_def.strip()) val_position = val_position[iv] # value for the last variable of the assignment val_def = vals_def[-1] iv, _ = vars[-1].domain.to_domain_value(val_def.strip()) val_position[iv] = value constraints[c_name] = NAryMatrixRelation(vars, values, name=c_name) else: raise ValueError( "Error in contraints {} definition: type is mandatory " 'and must be "intention" or "intensional"'.format(c_name) ) return constraints def _yaml_constraints(constraints: Iterable[RelationProtocol]): constraints_dict = {} for r in constraints: if hasattr(r, "expression"): constraints_dict[r.name] = {"type": "intention", "function": r.expression} else: # fallback to extensional constraint variables = [v.name for v in r.dimensions] values = defaultdict(lambda: []) for assignment in generate_assignment_as_dict(r.dimensions): val = r(**assignment) ass_str = " ".join([str(assignment[var]) for var in variables]) values[val].append(ass_str) for val in values: values[val] = " | ".join(values[val]) values = dict(values) constraints_dict[r.name] = { "type": "extensional", "variables": variables, "values": values, } return yaml.dump({"constraints": constraints_dict}, default_flow_style=False) def _build_agents(loaded) -> Dict[str, AgentDef]: # Read agents list, without creating AgentDef object yet. # We need the preferences to create the AgentDef objects agents_list = {} if "agents" in loaded: for a_name in loaded["agents"]: try: kw = loaded["agents"][a_name] # we accept any attribute for the agent # Most of the time it will be capacity and also preference but # any named value is valid: agents_list[a_name] = kw if kw else {} except TypeError: # means agents are given as a list and not a map: agents_list[a_name] = {} routes = {} default_route = 1 if "routes" in loaded: for a1 in loaded["routes"]: if a1 == "default": default_route = loaded["routes"]["default"] continue if a1 not in agents_list: raise DcopInvalidFormatError("Route for unknown " "agent " + a1) a1_routes = loaded["routes"][a1] for a2 in a1_routes: if a2 not in agents_list: raise DcopInvalidFormatError("Route for unknown " "agent " + a2) if (a2, a1) in routes or (a1, a2) in routes: if routes[(a2, a1)] != a1_routes[a2]: raise DcopInvalidFormatError( "Multiple route definition r{} = {}" " != r({}) = {}".format( (a2, a1), routes[(a2, a1)], (a1, a2), a1_routes[a2] ) ) routes[(a1, a2)] = a1_routes[a2] hosting_costs = {} default_cost = 0 default_agt_costs = {} if "hosting_costs" in loaded: costs = loaded["hosting_costs"] for a in costs: if a == "default": default_cost = costs["default"] continue if a not in agents_list: raise DcopInvalidFormatError("hosting_costs for unknown " "agent " + a) a_costs = costs[a] if "default" in a_costs: default_agt_costs[a] = a_costs["default"] if "computations" in a_costs: for c in a_costs["computations"]: hosting_costs[(a, c)] = a_costs["computations"][c] # Now that we parsed all agents info, we can build the objects: agents = {} for a in agents_list: d = default_cost if a in default_agt_costs: d = default_agt_costs[a] p = {c: hosting_costs[b, c] for (b, c) in hosting_costs if b == a} routes_a = {a2: v for (a1, a2), v in routes.items() if a1 == a} routes_a.update({a1: v for (a1, a2), v in routes.items() if a2 == a}) agents[a] = AgentDef( a, default_hosting_cost=d, hosting_costs=p, default_route=default_route, routes=routes_a, **agents_list[a] ) return agents def yaml_agents(agents: List[AgentDef]) -> str: """ Serialize a list of agents into a json string. Parameters ---------- agents: list a list of agents Returns ------- string: a json string representing the list of agents """ agt_dict = {} hosting_costs = {} routes = {} for agt in agents: if hasattr(agt, "capacity"): agt_dict[agt.name] = {"capacity": agt.capacity} else: agt_dict[agt.name] = {} if agt.default_hosting_cost or agt.hosting_costs: hosting_costs[agt.name] = { "default": agt.default_hosting_cost, "computations": agt.hosting_costs, } if agt.routes: routes[agt.name] = agt.routes if agt.default_route is not None: routes["default"] = agt.default_route res = {} if agt_dict: res["agents"] = agt_dict if routes: res["routes"] = routes if hosting_costs: res["hosting_costs"] = hosting_costs if res: return yaml.dump(res, default_flow_style=False) else: return "" def _build_dist_hints(loaded, dcop): if "distribution_hints" not in loaded: return None loaded = loaded["distribution_hints"] must_host, host_with = None, None if "must_host" in loaded: for a in loaded["must_host"]: if a not in dcop.agents: raise ValueError( "Cannot use must_host with unknown agent " "{}".format(a) ) for c in loaded["must_host"][a]: if c not in dcop.variables and c not in dcop.constraints: raise ValueError( "Cannot use must_host with unknown " "variable or constraint {}".format(c) ) must_host = loaded["must_host"] if "host_with" in loaded: host_with = defaultdict(lambda: set()) for i in loaded["host_with"]: host_with[i].update(loaded["host_with"][i]) for j in loaded["host_with"][i]: s = {i}.union(loaded["host_with"][i]) s.remove(j) host_with[j].update(s) return DistributionHints( must_host, dict(host_with) if host_with is not None else {} ) def str_2_domain_values(domain_str): """ Deserialize a domain expressed as a string. If all variable in the domain can be interpreted as a int, the list is a list of int, otherwise it is a list of strings. :param domain_str: a string like 0..5 of A, B, C, D :return: the list of values in the domain """ try: sep_index = domain_str.index("..") # Domain str is : [0..5] min_d = int(domain_str[0:sep_index]) max_d = int(domain_str[sep_index + 2 :]) return list(range(min_d, max_d + 1)) except ValueError: values = [v.strip() for v in domain_str[1:].split(",")] try: return [int(v) for v in values] except ValueError: return values def load_scenario_from_file(filename: str) -> Scenario: """ Load a scenario from a yaml file. :param filename: :return: """ with open(filename, mode="r", encoding="utf-8") as f: content = f.read() if content: return load_scenario(content) def load_scenario(scenario_str) -> Scenario: """ Load a scenario from a yaml string. :param scenario_str: :return: """ loaded = yaml.load(scenario_str, Loader=yaml.FullLoader) evts = [] for evt in loaded["events"]: id_evt = evt["id"] if "actions" in evt: actions = [] for a in evt["actions"]: args = dict(a) args.pop("type") actions.append(EventAction(a["type"], **args)) evts.append(DcopEvent(id_evt, actions=actions)) elif "delay" in evt: evts.append(DcopEvent(id_evt, delay=evt["delay"])) return Scenario(evts) def yaml_scenario(scenario: Scenario) -> str: events = [_dict_event(event) for event in scenario.events] scenario_dict = {"events": events} return yaml.dump(scenario_dict, default_flow_style=False) def _dict_event(event: DcopEvent) -> Dict: evt_dict = {"id": event.id} if event.is_delay: evt_dict["delay"] = event.delay else: print(f" event {event}") evt_dict["actions"] = [_dict_action(a) for a in event.actions] return evt_dict def _dict_action(action: EventAction) -> Dict: action_dict = {"type": action.type} action_dict.update(action.args) return action_dict