import ast
import warnings
from typing import Callable, Dict, List, Optional, Tuple
import networkx as nx
from numpy.random.mtrand import RandomState
from flatland.core.effects_generator import EffectsGenerator
from flatland.core.env_observation_builder import ObservationBuilder, DummyObservationBuilder
from flatland.core.graph.graph_resource_map import GraphResourceMap
from flatland.envs.agent_utils import EnvAgent
from flatland.envs.graph.distance_map import GraphDistanceMap
from flatland.envs.graph.rail_graph_transition_map import GraphTransitionMap
from flatland.envs.malfunction_generators import MalfunctionGenerator, ParamMalfunctionGen
from flatland.envs.rail_env import RailEnv, AbstractRailEnv
from flatland.envs.rewards import Rewards
from flatland.envs.step_utils.speed_counter import SpeedCounter
from flatland.envs.timetable_utils import Line, TimetableUtils
from flatland.utils.seeding import random_state_to_hashablestate, random_state_from_hashablestate
TimetableGenerator = Callable[[List[EnvAgent], GraphDistanceMap, dict, RandomState], "Timetable"]
[docs]
class GraphRailEnv(AbstractRailEnv[GraphTransitionMap, GraphResourceMap, str]):
[docs]
@staticmethod
def from_rail_env(rail_env: RailEnv, observation_builder: ObservationBuilder, seed: Optional[int] = None,
rewards: Rewards = None) -> "GraphRailEnv":
"""
Parameters
----------
rewards: Rewards, optional
the `Rewards` instance for `graph_env` to accumulate its own reward state into - must be a
separate instance from `rail_env.rewards` (never the same object: `Rewards` accumulates
mutable per-episode state, e.g. `arrivals`/`departures`/`states`, which `rail_env` and
`graph_env` must not share/corrupt). Defaults to `GraphRailEnv`'s own default (`None`) if
not given - pass a fresh instance matching `rail_env.rewards`'s type/config for parity. If
the resulting `graph_env.rewards` type ends up different from `rail_env.rewards`'s, a
`UserWarning` is raised (e.g. if `rewards` is left `None` while `rail_env.rewards` is not
`GraphRailEnv`'s own default).
"""
g = GraphTransitionMap.grid_to_digraph(rail_env.rail)
resource_map = GraphRailEnv._grid_resource_map(rail_env, g)
agent_waypoints, agent_speeds = GraphRailEnv._grid_agent_waypoints_and_speeds(rail_env)
timetable = TimetableUtils.from_agents(rail_env.agents, rail_env._max_episode_steps)
graph_env = GraphRailEnv.from_graph(
g=g,
resource_map=resource_map,
agent_waypoints=agent_waypoints,
agent_speeds=agent_speeds,
observation_builder=observation_builder,
# TODO https://github.com/flatland-association/flatland-rl/issues/242 generalize malfunction generator injection
# N.B. ParamMalfunctionGen is not stateless due to cached random nums, see https://github.com/flatland-association/flatland-rl/issues/364.
malfunction_generator=ParamMalfunctionGen(rail_env.malfunction_generator.MFP),
timetable_generator=lambda *args, **kwargs: timetable,
seed=seed,
rewards=rewards,
)
GraphRailEnv._warn_if_rewards_mismatch(rail_env, graph_env)
# TODO https://github.com/flatland-association/flatland-rl/pull/341 hack while awaiting this pr
s = random_state_to_hashablestate(rail_env.np_random)
graph_env.np_random = random_state_from_hashablestate(s)
return graph_env
@staticmethod
def _grid_resource_map(rail_env: RailEnv, g: nx.DiGraph) -> Dict[str, str]:
"""
Maps each grid-derived graph node to its resource: the underlying `(row, col)` cell, or
`(row, col, direction % 2)` for a level-free (diamond) crossing so the two crossing axes count
as distinct resources - mirrors `GridResourceMap.get_resource()`.
"""
resource_map = {}
for n in g.nodes:
r, c, d = ast.literal_eval(n)
if (r, c) in rail_env.resource_map.level_free_positions:
resource_map[n] = str((r, c, d % 2))
else:
resource_map[n] = str((r, c))
return resource_map
@staticmethod
def _grid_agent_waypoints_and_speeds(rail_env: RailEnv) -> Tuple[Dict[int, List[List[str]]], Dict[int, float]]:
"""Converts `rail_env`'s agents' grid `Waypoint`-based waypoints/speeds into the plain
string-keyed shape `from_graph` expects."""
gctgc = GraphTransitionMap.grid_configuration_to_graph_configuration
agent_waypoints = {
agent.handle: [[gctgc(*wp.position, wp.direction) for wp in group] for group in agent.waypoints]
for agent in rail_env.agents
}
agent_speeds = {agent.handle: agent.speed_counter.max_speed for agent in rail_env.agents}
return agent_waypoints, agent_speeds
@staticmethod
def _warn_if_rewards_mismatch(rail_env: RailEnv, graph_env: "GraphRailEnv") -> None:
if type(rail_env.rewards) is not type(graph_env.rewards):
warnings.warn(
f"rail_env.rewards is {type(rail_env.rewards).__name__}, but graph_env.rewards is "
f"{type(graph_env.rewards).__name__} (no matching `rewards` was passed to from_rail_env) - "
f"rewards will not be directly comparable between the two envs."
)
[docs]
@staticmethod
def from_graph(
g: nx.DiGraph,
resource_map: Dict[str, str],
agent_waypoints: Dict[int, List[List[str]]],
agent_speeds: Optional[Dict[int, float]] = None,
observation_builder: ObservationBuilder = None,
malfunction_generator: "MalfunctionGenerator" = None,
timetable_generator: Optional[TimetableGenerator] = None,
seed: Optional[int] = None,
rewards: Rewards = None,
) -> "GraphRailEnv":
"""
Factory method to create a `GraphRailEnv` directly from a string-node graph and string-based
agent waypoints - counterpart to `from_rail_env`, but graph-native from the start: `g`'s nodes
and `agent_waypoints`' leaves are plain configuration strings, never `((row, col), direction)`
grid tuples or `Waypoint` objects.
Parameters
----------
g: nx.DiGraph
the rail topology, with `actions`/`straight` edge attributes and an optional
`prohibited_actions` node attribute - see `GraphTransitionMap.grid_to_digraph` for the
shape expected by `RailEnv.step()`.
resource_map: Dict[str, str]
maps each node in `g` to the resource (occupancy unit) used for conflict detection.
agent_waypoints: Dict[int, List[List[str]]]
per agent handle, the list of waypoint alternative-groups (initial, any intermediate
stops, target) - mirrors `Line.agent_waypoints`, but with plain node-id strings instead of
`Waypoint` objects.
agent_speeds: Dict[int, float], optional
per agent handle, the agent's speed - defaults to `1.0` for every agent.
timetable_generator: optional
`(agents, distance_map, agents_hints, np_random) -> Timetable` - defaults to
`ttg.ttgen_flatland2` (`earliest_departure=0`/`latest_arrival=1000` for every agent). Pass
e.g. `lambda *a, **k: TimetableUtils.from_agents(source_agents, max_episode_steps)` to
reuse an existing timetable instead (mirrors how `from_rail_env` reuses its source env's).
"""
timetable_generator, agent_speeds = GraphRailEnv._resolve_from_graph_defaults(
timetable_generator, agent_speeds, agent_waypoints)
gtm = GraphTransitionMap(g)
line = Line(agent_waypoints=agent_waypoints, agent_speeds=agent_speeds)
graph_env = GraphRailEnv(
number_of_agents=len(agent_waypoints),
rail_generator=lambda *args, **kwargs: ({"resource_map": resource_map}, gtm),
line_generator=lambda *args, **kwargs: line,
timetable_generator=timetable_generator,
observation_builder=observation_builder,
malfunction_generator=malfunction_generator,
rewards=rewards,
)
graph_env.reset(random_seed=seed)
return graph_env
@staticmethod
def _resolve_from_graph_defaults(
timetable_generator: Optional[TimetableGenerator],
agent_speeds: Optional[Dict[int, float]],
agent_waypoints: Dict[int, List[List[str]]],
) -> Tuple[TimetableGenerator, Dict[int, float]]:
"""Resolves `from_graph`'s optional `timetable_generator`/`agent_speeds` to their concrete
defaults (`ttg.ttgen_flatland2`, uniform speed `1.0`) - a local import avoids a circular import
with `flatland.envs.timetable_generators` at module load time."""
import flatland.envs.timetable_generators as ttg
if timetable_generator is None:
timetable_generator = ttg.ttgen_flatland2
if agent_speeds is None:
agent_speeds = {handle: 1.0 for handle in agent_waypoints}
return timetable_generator, agent_speeds
def __init__(
self,
# TODO https://github.com/flatland-association/flatland-rl/issues/242 fix signature
rail_generator: "RailGenerator" = None,
line_generator: "LineGenerator" = None,
number_of_agents=2,
observation_builder: ObservationBuilder = None,
malfunction_generator_and_process_data=None,
malfunction_generator: "MalfunctionGenerator" = None,
random_seed=None,
timetable_generator=None,
acceleration_delta=1.0,
braking_delta=-1.0,
rewards: Rewards = None,
effects_generator: EffectsGenerator["GraphRailEnv"] = None,
distance_map: GraphDistanceMap = None
):
if observation_builder is None:
observation_builder = DummyObservationBuilder()
super().__init__(
rail_generator=rail_generator,
line_generator=line_generator,
number_of_agents=number_of_agents,
obs_builder_object=observation_builder,
malfunction_generator_and_process_data=malfunction_generator_and_process_data,
malfunction_generator=malfunction_generator,
random_seed=random_seed,
timetable_generator=timetable_generator,
acceleration_delta=acceleration_delta,
braking_delta=braking_delta,
rewards=rewards,
effects_generator=effects_generator,
distance_map=GraphDistanceMap([]) if distance_map is None else distance_map,
)
self.agents = [EnvAgent(None, None, None) for i in range(self.get_num_agents())]
[docs]
def get_num_agents(self) -> int:
return self.number_of_agents
def _extract_resource_map_from_optionals(self, optionals: dict) -> GraphResourceMap:
if "resource_map" in optionals:
return GraphResourceMap(optionals["resource_map"])
else:
return GraphResourceMap({})
def _infrastructure_representation(self, configuration: str) -> str:
return configuration
def _agents_from_line(self, line: "Line", rail: GraphTransitionMap) -> List[EnvAgent[str]]:
"""
Builds `EnvAgent`s directly from a `Line` whose `agent_waypoints` are plain graph node-id
strings - counterpart to `EnvAgent.from_line` for a graph-native `Line` (no `Waypoint` objects
or grid `((row, col), direction)` tuples involved at all).
"""
agents = []
for handle, waypoints in line.agent_waypoints.items():
speed = line.agent_speeds[handle] if line.agent_speeds is not None else 1.0
waypoints = list(waypoints)
# N.B. only the target's alternatives (last waypoint group) can be invalid - the caller's
# own routing already guarantees valid configurations everywhere else.
waypoints[-1] = [t for t in waypoints[-1] if rail.is_valid_configuration(t)]
assert len(waypoints[-1]) > 0, (
f"agent {handle}: none of the target alternatives {list(line.agent_waypoints[handle][-1])} "
f"are valid configurations in the graph - the agent would end up with an empty `targets`."
)
initial_configuration = waypoints[0][0]
agents.append(EnvAgent(
initial_configuration=initial_configuration,
current_configuration=initial_configuration,
old_configuration=None,
targets=set(waypoints[-1]),
waypoints=waypoints,
moving=False,
earliest_departure=None,
latest_arrival=None,
waypoints_earliest_departure=None,
waypoints_latest_arrival=None,
handle=handle,
speed_counter=SpeedCounter(speed=speed)))
return agents