├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Machine-Teaching-Examples ├── moab_action_transform.ink ├── moab_pomdp.ink ├── moab_state_transform.ink ├── moab_stochastic.ink ├── moab_with_noise.ink └── model_import │ ├── LAW.png │ ├── README.md │ ├── environment.yml │ ├── imported-concept-graph.png │ ├── moab-imported-concept.ink │ ├── state_transform_deep.onnx │ ├── state_transform_deep.zip │ ├── state_transform_keras_model.ipynb │ └── training-graph.png ├── README.md ├── SECURITY.md ├── assess_config.json ├── moab.sh ├── moab_experiment.ink ├── moab_interface.json ├── moab_model.py ├── moab_sim.py ├── moab_tutorial_1.ink ├── moab_tutorial_2.ink ├── moab_tutorial_3.ink ├── requirements.txt ├── test_moab_model.py ├── test_moab_perf.py ├── test_moab_sim.py └── tests ├── __init__.py ├── conftest.py └── test_model_import.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__ 3 | build 4 | test-results 5 | 6 | # Internal inkling that is not to be pushed to public github 7 | base.ink 8 | moab_tutorial_1_tpl.ink -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to 4 | agree to a Contributor License Agreement (CLA) declaring that you have the right to, 5 | and actually do, grant us the rights to use your contribution. For details, visit 6 | https://cla.microsoft.com. 7 | 8 | When you submit a pull request, a CLA-bot will automatically determine whether you need 9 | to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the 10 | instructions provided by the bot. You will only need to do this once across all repositories using our CLA. 11 | 12 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 13 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 14 | or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # this is one of the cached base images available for ACI 2 | FROM python:3.7.4 3 | 4 | # Install libraries and dependencies 5 | RUN apt-get update && \ 6 | apt-get install -y --no-install-recommends \ 7 | && rm -rf /var/lib/apt/lists/* 8 | 9 | # Install dependencies 10 | RUN pip3 install -U setuptools \ 11 | && pip3 install git+https://github.com/microsoft/bonsai-common \ 12 | && pip3 uninstall -y setuptools 13 | 14 | # Set up the simulator 15 | WORKDIR /sim 16 | 17 | # Copy simulator files to /sim 18 | COPY ./ /sim 19 | 20 | # Install simulator dependencies 21 | RUN pip3 install -r requirements.txt 22 | 23 | # This will be the command to run the simulator 24 | CMD ["python", "moab_sim.py"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 - Present, Microsoft Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Machine-Teaching-Examples/moab_action_transform.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab Sample illustrating how to use an action transform to train a concept 8 | # with an action space that differs from the actions expected by the simulator. 9 | 10 | ### 11 | 12 | inkling "2.0" 13 | 14 | using Math 15 | using Goal 16 | 17 | # Distances measured in meters 18 | const RadiusOfPlate = 0.1125 # m 19 | 20 | # Velocities measured in meters per sec. 21 | const MaxVelocity = 6.0 22 | const MaxInitialVelocity = 1.0 23 | 24 | # Threshold for ball placement 25 | const CloseEnough = 0.02 26 | 27 | # Default time delta between simulation steps (s) 28 | const DefaultTimeDelta = 0.045 29 | 30 | # Maximum distance per step in meters 31 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 32 | 33 | # Maximum plate angle in degrees 34 | const MaxPlateAngle = 22 35 | 36 | # State received from the simulator after each iteration 37 | type ObservableState { 38 | # Ball X,Y position 39 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 40 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 41 | 42 | # Ball X,Y velocity 43 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 44 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 45 | } 46 | 47 | # Action learned by the concept, and passed to the action transform. 48 | # pitch and roll actions in degrees. 49 | type BrainAction { 50 | # Range is in degrees 51 | # the full plate rotation range supported by the hardware. 52 | input_pitch: number<-MaxPlateAngle .. MaxPlateAngle>, # rotate about x-axis 53 | input_roll: number<-MaxPlateAngle .. MaxPlateAngle>, # rotate about y-axis 54 | } 55 | 56 | # Action expected by the sim, with pitch and roll normalized between -1 and 1 57 | type SimAction { 58 | # Range -1 to 1 is a scaled value that represents 59 | # the full plate rotation range supported by the hardware. 60 | input_pitch: number<-1 .. 1>, # rotate about x-axis 61 | input_roll: number<-1 .. 1>, # rotate about y-axis 62 | } 63 | 64 | # Per-episode configuration that can be sent to the simulator. 65 | type SimConfig { 66 | # Model initial ball conditions 67 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 68 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 69 | 70 | # Model initial ball velocity conditions 71 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 72 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 73 | 74 | # Range -1 to 1 is a scaled value that represents 75 | # the full plate rotation range supported by the hardware. 76 | initial_pitch: number<-1 .. 1>, 77 | initial_roll: number<-1 .. 1>, 78 | } 79 | 80 | # Action transform function definition from degrees to normalized 81 | function TransformAction(a: BrainAction): SimAction { 82 | return { 83 | input_pitch: a.input_pitch / MaxPlateAngle, 84 | input_roll: a.input_roll / MaxPlateAngle, 85 | } 86 | } 87 | 88 | # Define a concept graph with a single concept 89 | graph (input: ObservableState) { 90 | concept MoveToCenter(input): BrainAction { 91 | curriculum { 92 | # The source of training for this concept is a simulator that 93 | # - can be configured for each episode using fields defined in SimConfig, 94 | # - accepts per-iteration actions defined in SimAction, and 95 | # - outputs states with the fields defined in SimState. 96 | source simulator MoabSim(Action: SimAction, Config: SimConfig): ObservableState { 97 | # Automatically launch the simulator with this 98 | # registered package name. 99 | package "Moab" 100 | } 101 | 102 | training { 103 | # Limit episodes to 250 iterations instead of the default 1000. 104 | EpisodeIterationLimit: 250 105 | } 106 | 107 | # Specify the action transformation function used when training or assessing using the simulator. 108 | # This transform is _not_ included when the brain is exported — it is only used in training and assessment. 109 | action TransformAction 110 | 111 | # The objective of training is expressed as a goal with two 112 | # subgoals: don't let the ball fall off the plate, and drive 113 | # the ball to the center of the plate. 114 | goal (State: ObservableState) { 115 | avoid `Fall Off Plate`: Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.8) 116 | drive `Center Of Plate`: [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 117 | } 118 | 119 | lesson `Randomize Start` { 120 | # Specify the configuration parameters that should be varied 121 | # from one episode to the next during this lesson. 122 | scenario { 123 | initial_x: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 124 | initial_y: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 125 | 126 | initial_vel_x: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 127 | initial_vel_y: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 128 | 129 | initial_pitch: number<-0.2 .. 0.2>, 130 | initial_roll: number<-0.2 .. 0.2>, 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | # Special string to hook up the simulator visualizer 138 | # in the web interface. 139 | const SimulatorVisualizer = "/moabviz/" 140 | -------------------------------------------------------------------------------- /Machine-Teaching-Examples/moab_pomdp.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Sample illustrating how brain memory helps solve partially observable 8 | # problems, where the available sensor information does not provide enough 9 | # information to control the system. The system learns to control the actions 10 | # based on the history of the system dynamics. 11 | 12 | # Note: This sample includes only the ball position in the observable state, 13 | # removing velocity. This is for illustration, and not recommended in real 14 | # problems: if there is information that you know is helpful for solving the 15 | # problem, like ball velocity for balancing a ball on a plate, include them! 16 | # Memory helps capture other history-dependent features that are difficulty 17 | # to explicitly define. 18 | 19 | ### 20 | 21 | inkling "2.0" 22 | 23 | using Math 24 | using Goal 25 | 26 | # Distances measured in meters 27 | const RadiusOfPlate = 0.1125 # m 28 | 29 | # Velocities measured in meters per sec. 30 | const MaxVelocity = 6.0 31 | const MaxInitialVelocity = 1.0 32 | 33 | # Threshold for ball placement 34 | const CloseEnough = 0.02 35 | 36 | # Default time delta between simulation steps (s) 37 | const DefaultTimeDelta = 0.045 38 | 39 | # Maximum distance per step in meters 40 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 41 | 42 | # State received from the simulator after each iteration 43 | type SimState { 44 | # Ball X,Y position 45 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 46 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 47 | 48 | # Ball X,Y velocity 49 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 50 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 51 | } 52 | 53 | # Deliberately remove ball velocity from the state passed to the brain, and 54 | # give it only the ball position. This turns this problem into a Partially 55 | # Observable Markov Decision Process (POMDP) 56 | type ObservableState { 57 | # Ball X,Y position 58 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 59 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 60 | } 61 | 62 | # Action provided as output by policy and sent as 63 | # input to the simulator 64 | type SimAction { 65 | # Range -1 to 1 is a scaled value that represents 66 | # the full plate rotation range supported by the hardware. 67 | input_pitch: number<-1 .. 1>, # rotate about x-axis 68 | input_roll: number<-1 .. 1>, # rotate about y-axis 69 | } 70 | 71 | # Per-episode configuration that can be sent to the simulator. 72 | # All iterations within an episode will use the same configuration. 73 | type SimConfig { 74 | # Model initial ball conditions 75 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 76 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 77 | 78 | # Model initial ball velocity conditions 79 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 80 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 81 | 82 | # Range -1 to 1 is a scaled value that represents 83 | # the full plate rotation range supported by the hardware. 84 | initial_pitch: number<-1 .. 1>, 85 | initial_roll: number<-1 .. 1>, 86 | } 87 | 88 | # Define a concept graph with a single concept 89 | graph (input: ObservableState) { 90 | concept MoveToCenter(input): SimAction { 91 | curriculum { 92 | # The source of training for this concept is a simulator that 93 | # - can be configured for each episode using fields defined in SimConfig, 94 | # - accepts per-iteration actions defined in SimAction, and 95 | # - outputs states with the fields defined in SimState. 96 | source simulator MoabSim(Action: SimAction, Config: SimConfig): SimState { 97 | # Automatically launch the simulator with this 98 | # registered package name. 99 | package "Moab" 100 | } 101 | 102 | training { 103 | # Limit episodes to 250 iterations instead of the default 1000. 104 | EpisodeIterationLimit: 250 105 | } 106 | 107 | algorithm { 108 | # Use supported values: 109 | # default - AI engine will choose automatically 110 | # none - no memory, learned actions depend on current state 111 | # state - memory of past states 112 | # state and action - memory of past states and actions. 113 | MemoryMode: "state and action" 114 | } 115 | 116 | # The objective of training is expressed as a goal with two 117 | # subgoals: don't let the ball fall off the plate, and drive 118 | # the ball to the center of the plate. 119 | goal (State: SimState) { 120 | avoid `Fall Off Plate`: 121 | Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.8) 122 | drive `Center Of Plate`: 123 | [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 124 | } 125 | 126 | lesson `Randomize Start` { 127 | # Specify the configuration parameters that should be varied 128 | # from one episode to the next during this lesson. 129 | scenario { 130 | initial_x: number<-RadiusOfPlate * 0.6 .. RadiusOfPlate * 0.6>, 131 | initial_y: number<-RadiusOfPlate * 0.6 .. RadiusOfPlate * 0.6>, 132 | 133 | initial_vel_x: number<-MaxInitialVelocity * 0.2 .. MaxInitialVelocity * 0.2>, 134 | initial_vel_y: number<-MaxInitialVelocity * 0.2 .. MaxInitialVelocity * 0.2>, 135 | 136 | initial_pitch: number<-0.1 .. 0.1>, 137 | initial_roll: number<-0.1 .. 0.1>, 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | # Special string to hook up the simulator visualizer 145 | # in the web interface. 146 | const SimulatorVisualizer = "/moabviz/" -------------------------------------------------------------------------------- /Machine-Teaching-Examples/moab_state_transform.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2020 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab Sample illustrating how to use a state transform to train a brain 8 | # with an input space that differs from the states output by the simulator. 9 | 10 | # When using an exported brain with this sample, ensure to use the 11 | # ObservableState type definition from the environment 12 | 13 | ### 14 | 15 | inkling "2.0" 16 | 17 | using Math 18 | using Goal 19 | 20 | # Distances measured in meters 21 | const RadiusOfPlate = 0.1125 # m 22 | 23 | # Velocities measured in meters per sec. 24 | const MaxVelocity = 6.0 25 | const MaxInitialVelocity = 1.0 26 | 27 | # Threshold for ball placement 28 | const CloseEnough = 0.02 29 | 30 | # Default time delta between simulation steps (s) 31 | const DefaultTimeDelta = 0.045 32 | 33 | # Maximum distance per step in meters 34 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 35 | 36 | # What velocity do we want the ball to have when it reaches the target? 37 | # (This could be configurable in the sim to make it non-constant. Kept here for simplicity.) 38 | const TargetVelocityX = 0 39 | const TargetVelocityY = 0 40 | 41 | # State received from the simulator after each iteration. It includes absolute positions and velocities. 42 | type SimState { 43 | # Ball X,Y position 44 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 45 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 46 | 47 | # Ball X,Y velocity 48 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 49 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 50 | 51 | # Target stationary X,Y position 52 | target_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 53 | target_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 54 | } 55 | 56 | # Brain input state, consisting of error from target value for position and velocity. 57 | type ObservableState { 58 | # Ball X,Y position 59 | ball_x_error: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 60 | ball_y_error: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 61 | 62 | # Ball X,Y velocity 63 | ball_vel_x_error: number<-MaxVelocity .. MaxVelocity>, 64 | ball_vel_y_error: number<-MaxVelocity .. MaxVelocity>, 65 | } 66 | 67 | # Action provided as output by policy and sent as 68 | # input to the simulator 69 | type SimAction { 70 | # Range -1 to 1 is a scaled value that represents 71 | # the full plate rotation range supported by the hardware. 72 | input_pitch: number<-1 .. 1>, # rotate about x-axis 73 | input_roll: number<-1 .. 1>, # rotate about y-axis 74 | } 75 | 76 | # Per-episode configuration that can be sent to the simulator. 77 | type SimConfig { 78 | # Model initial ball conditions 79 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 80 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 81 | 82 | # Model initial ball velocity conditions 83 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 84 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 85 | 86 | # Range -1 to 1 is a scaled value that represents 87 | # the full plate rotation range supported by the hardware. 88 | initial_pitch: number<-1 .. 1>, 89 | initial_roll: number<-1 .. 1>, 90 | 91 | # Target X,Y position 92 | target_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 93 | target_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 94 | } 95 | 96 | # State transform function definition from absolute to error 97 | # like commonly found in control theory 98 | function TransformState (s: SimState): ObservableState { 99 | return { 100 | ball_x_error: s.target_x - s.ball_x, 101 | ball_y_error: s.target_y - s.ball_y, 102 | ball_vel_x_error: TargetVelocityX - s.ball_vel_x, 103 | ball_vel_y_error: TargetVelocityY - s.ball_vel_y, 104 | } 105 | } 106 | 107 | # Define a concept graph with a single concept 108 | graph (input: ObservableState) { 109 | concept MoveToCenter(input): SimAction { 110 | curriculum { 111 | # The source of training for this concept is a simulator that 112 | # - can be configured for each episode using fields defined in SimConfig, 113 | # - accepts per-iteration actions defined in SimAction, and 114 | # - outputs states with the fields defined in SimState. 115 | source simulator MoabSim(Action: SimAction, Config: SimConfig): SimState { 116 | # Automatically launch the simulator with this 117 | # registered package name. 118 | package "Moab" 119 | } 120 | 121 | training { 122 | # Limit episodes to 250 iterations instead of the default 1000. 123 | EpisodeIterationLimit: 250 124 | } 125 | 126 | # Specify the state transformation function used when training or assessing using the simulator. 127 | # This transform is _not_ included when the brain is exported — it is only used in training and assessment. 128 | state TransformState 129 | 130 | # The objective of training is expressed as a goal with two 131 | # subgoals: don't let the ball fall off the plate, and drive 132 | # the ball to the center of the plate. 133 | goal (State: SimState) { 134 | avoid `Fall Off Plate`: Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.8) 135 | drive `Center Of Plate`: [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 136 | } 137 | 138 | lesson `Randomize Start` { 139 | # Specify the configuration parameters that should be varied 140 | # from one episode to the next during this lesson. 141 | scenario { 142 | initial_x: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 143 | initial_y: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 144 | 145 | initial_vel_x: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 146 | initial_vel_y: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 147 | 148 | initial_pitch: number<-0.2 .. 0.2>, 149 | initial_roll: number<-0.2 .. 0.2>, 150 | 151 | target_x: 0, 152 | target_y: 0, 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | # Special string to hook up the simulator visualizer 160 | # in the web interface. 161 | const SimulatorVisualizer = "/moabviz/" 162 | -------------------------------------------------------------------------------- /Machine-Teaching-Examples/moab_stochastic.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab example demonstrating AI learning a general policy despite being 8 | # stochastic because noise is added to the actuator for plate angles. 9 | # The noise is a gaussian with a mean of zero and a sigma of scalar/3 10 | 11 | ### 12 | 13 | inkling "2.0" 14 | 15 | using Math 16 | using Goal 17 | 18 | # Distances measured in meters 19 | const RadiusOfPlate = 0.1125 # m 20 | 21 | # Velocities measured in meters per sec. 22 | const MaxVelocity = 6.0 23 | const MaxInitialVelocity = 1.0 24 | 25 | # Threshold for ball placement 26 | const CloseEnough = 0.02 27 | 28 | # Default time delta between simulation steps (s) 29 | const DefaultTimeDelta = 0.045 30 | 31 | # Maximum distance per step in meters 32 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 33 | 34 | # State received from the simulator after each iteration 35 | type SimState { 36 | # Ball X,Y position 37 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 38 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 39 | 40 | # Ball X,Y velocity 41 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 42 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 43 | 44 | # Actual plate theta X, Y 45 | plate_theta_x: number, 46 | plate_theta_y: number 47 | } 48 | 49 | # State to send to the brain with simulated noise 50 | type ObservableState { 51 | # Ball X,Y position 52 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 53 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 54 | 55 | # Ball X,Y velocity 56 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 57 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 58 | } 59 | 60 | # Action provided as output by policy and sent as 61 | # input to the simulator 62 | type SimAction { 63 | # Range -1 to 1 is a scaled value that represents 64 | # the full plate rotation range supported by the hardware. 65 | input_pitch: number<-1 .. 1>, # rotate about x-axis 66 | input_roll: number<-1 .. 1>, # rotate about y-axis 67 | } 68 | 69 | # Per-episode configuration that can be sent to the simulator. 70 | # All iterations within an episode will use the same configuration. 71 | type SimConfig { 72 | # Model initial ball conditions 73 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 74 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 75 | 76 | # Model initial ball velocity conditions 77 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 78 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 79 | 80 | # Range -1 to 1 is a scaled value that represents 81 | # the full plate rotation range supported by the hardware. 82 | initial_pitch: number<-1 .. 1>, 83 | initial_roll: number<-1 .. 1>, 84 | 85 | # Add actuator noise to plate angles 86 | plate_noise: number, 87 | } 88 | 89 | # Define a concept graph with a single concept 90 | graph (input: ObservableState) { 91 | concept MoveToCenter(input): SimAction { 92 | curriculum { 93 | # The source of training for this concept is a simulator that 94 | # - can be configured for each episode using fields defined in SimConfig, 95 | # - accepts per-iteration actions defined in SimAction, and 96 | # - outputs states with the fields defined in SimState. 97 | source simulator MoabSim (Action: SimAction, Config: SimConfig): SimState { 98 | } 99 | 100 | # The objective of training is expressed as a goal with two 101 | # subgoals: don't let the ball fall off the plate, and drive 102 | # the ball to the center of the plate. 103 | goal (State: SimState) { 104 | avoid `Fall Off Plate`: 105 | Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.8) 106 | drive `Center Of Plate`: 107 | [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 108 | } 109 | 110 | training { 111 | # Limit episodes to 250 iterations instead of the default 1000. 112 | EpisodeIterationLimit: 250 113 | } 114 | 115 | lesson `Randomize Start` { 116 | # Specify the configuration parameters that should be varied 117 | # from one episode to the next during this lesson. 118 | scenario { 119 | initial_x: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 120 | initial_y: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 121 | 122 | initial_vel_x: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 123 | initial_vel_y: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 124 | 125 | initial_pitch: number<-0.2 .. 0.2>, 126 | initial_roll: number<-0.2 .. 0.2>, 127 | 128 | plate_noise: 0.017, # ~1 degree max 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | # Special string to hook up the simulator visualizer 136 | # in the web interface. 137 | const SimulatorVisualizer = "/moabviz/" -------------------------------------------------------------------------------- /Machine-Teaching-Examples/moab_with_noise.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab example demonstrating AI learning a general policy despite noise 8 | # added to the simulated sensors and plate angles. The noise is a gaussian 9 | # with a mean of zero and a sigma of scalar/3 10 | 11 | ### 12 | 13 | inkling "2.0" 14 | 15 | using Math 16 | using Goal 17 | 18 | # Distances measured in meters 19 | const RadiusOfPlate = 0.1125 # m 20 | 21 | # Velocities measured in meters per sec. 22 | const MaxVelocity = 6.0 23 | const MaxInitialVelocity = 1.0 24 | 25 | # Threshold for ball placement 26 | const CloseEnough = 0.02 27 | 28 | # Default time delta between simulation steps (s) 29 | const DefaultTimeDelta = 0.045 30 | 31 | # Maximum distance per step in meters 32 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 33 | 34 | # State received from the simulator after each iteration 35 | type SimState { 36 | # Ball X,Y position 37 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 38 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 39 | 40 | # Ball X,Y velocity 41 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 42 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 43 | 44 | # Estimated Ball X,Y position 45 | estimated_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 46 | estimated_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 47 | 48 | # Estimated Ball X,Y velocity 49 | estimated_vel_x: number<-MaxVelocity .. MaxVelocity>, 50 | estimated_vel_y: number<-MaxVelocity .. MaxVelocity>, 51 | } 52 | 53 | # State to send to the brain with simulated noise 54 | type ObservableState { 55 | # Ball X,Y position 56 | estimated_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 57 | estimated_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 58 | 59 | # Ball X,Y velocity 60 | estimated_vel_x: number<-MaxVelocity .. MaxVelocity>, 61 | estimated_vel_y: number<-MaxVelocity .. MaxVelocity>, 62 | } 63 | 64 | # Action provided as output by policy and sent as 65 | # input to the simulator 66 | type SimAction { 67 | # Range -1 to 1 is a scaled value that represents 68 | # the full plate rotation range supported by the hardware. 69 | input_pitch: number<-1 .. 1>, # rotate about x-axis 70 | input_roll: number<-1 .. 1>, # rotate about y-axis 71 | } 72 | 73 | # Per-episode configuration that can be sent to the simulator. 74 | # All iterations within an episode will use the same configuration. 75 | type SimConfig { 76 | # Model initial ball conditions 77 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 78 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 79 | 80 | # Model initial ball velocity conditions 81 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 82 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 83 | 84 | # Range -1 to 1 is a scaled value that represents 85 | # the full plate rotation range supported by the hardware. 86 | initial_pitch: number<-1 .. 1>, 87 | initial_roll: number<-1 .. 1>, 88 | 89 | # Add sensor noise to the states 90 | ball_noise: number, 91 | } 92 | 93 | # Define a concept graph with a single concept 94 | graph (input: ObservableState) { 95 | concept MoveToCenter(input): SimAction { 96 | curriculum { 97 | # The source of training for this concept is a simulator that 98 | # - can be configured for each episode using fields defined in SimConfig, 99 | # - accepts per-iteration actions defined in SimAction, and 100 | # - outputs states with the fields defined in SimState. 101 | source simulator MoabSim (Action: SimAction, Config: SimConfig): SimState { 102 | } 103 | 104 | # The objective of training is expressed as a goal with two 105 | # subgoals: don't let the ball fall off the plate, and drive 106 | # the ball to the center of the plate. 107 | goal (State: SimState) { 108 | avoid `Fall Off Plate`: 109 | Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.8) 110 | drive `Center Of Plate`: 111 | [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 112 | } 113 | 114 | training { 115 | # Limit episodes to 250 iterations instead of the default 1000. 116 | EpisodeIterationLimit: 250 117 | } 118 | 119 | lesson `Randomize Start` { 120 | # Specify the configuration parameters that should be varied 121 | # from one episode to the next during this lesson. 122 | scenario { 123 | initial_x: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 124 | initial_y: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 125 | 126 | initial_vel_x: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 127 | initial_vel_y: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 128 | 129 | initial_pitch: number<-0.2 .. 0.2>, 130 | initial_roll: number<-0.2 .. 0.2>, 131 | 132 | ball_noise: 0.010, # ~10 mm max 133 | } 134 | } 135 | } 136 | } 137 | } 138 | 139 | # Special string to hook up the simulator visualizer 140 | # in the web interface. 141 | const SimulatorVisualizer = "/moabviz/" -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/LAW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/moabsim-py/54bb72bf47f6c23607f910d0ba352c07f0a7ba73/Machine-Teaching-Examples/model_import/LAW.png -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/README.md: -------------------------------------------------------------------------------- 1 | # How To: build and import a tensorflow-keras model in bonsai 2 | 3 | This sample illustrates how to import a Machine Learning [ML] model in Bonsai: 4 | 1. We build a ML model using tensorflow-keras 5 | 1. note: other frameworks such as pure Tensorflow are supported, in this sample we focus on Keras 6 | 2. We illustrate by building a dummy deep transform: 7 | 1. A Keras model (deep neural net) that transforms the input by applying a non linear transformation 8 | 9 | 2. Save the ML model as either 10 | 1. `tensorflow SavedModel` format OR 11 | 2. `onnx` format 12 | 3. Import the saved model to the Bonsai platform using `bonsai-cli` 13 | 4. Use it in the Moab example. 14 | 15 | drawing 16 | 17 | ## Requirements 18 | 1. Create a conda virtual environment using the `environment.yml`. 19 | `conda env create -f environment.yml` 20 | 2. Currently only ML models compatible with tensorflow v1.15.2 are supported [(Link to docs)](https://docs.microsoft.com/en-us/bonsai/guides/import-ml-models?tabs=onnx%2Cnested1). 21 | 3. When saving a Keras model as a Tensorflow SavedModel, one needs to save it using `tf.saved_model.builder.SavedModelBuilder()`. Saving a Keras model using the direct `model.save()` is currently incompatible with Bonsai model import. (see *state_transform_keras_model.ipynb* Jupyter notebook) 22 | 23 | ## Usage 24 | 1. Using the *state_transform_keras_model.ipynb* notebook 25 | 1. Build a tensorflow-keras model 26 | 2. Save the ML model as `tensorflow SavedModel` format OR `onnx` format 27 | 2. Compressing the model as a `.zip` 28 | 1. If the ML model is saved as a `tensorflow SavedModel`, compress the saved_model folder as a `.zip` 29 | 2. If the ML model is saved as `.onnx`, there is no need to compress as `.zip` 30 | 3. Import the ML model to bonsai 31 | 1. `tensorflow SavedModel` format OR 32 | ``` 33 | bonsai importedmodel create 34 | --name 35 | --modelfilepath ./state_transform_deep.zip 36 | --description "state transform tf SavedModel" 37 | --display-name 38 | ``` 39 | 2. `onnx` format 40 | ``` 41 | bonsai importedmodel create 42 | --name 43 | --modelfilepath ./state_transform_deep.onnx 44 | --description "state transform ONNX" 45 | --display-name 46 | ``` 47 | 1. Use the imported model to train your brain using a Moab sample 48 | 1. On preview.bons.ai 49 | 1. Create a new brain from a "Moab sample" 50 | 2. Copy paste the inkling from *moab-imported-concept.ink* in the *Teach* panel UI. In this sample we use the imported ML model to transform the state before passing it as a brain input (see inkling excerpt below). 51 | ``` 52 | graph (input: ObservableState) { 53 | concept ImportedConcept(input): ObservableState { 54 | import {Model: } 55 | } 56 | concept MoveToCenter(ImportedConcept): SimAction { 57 | ... 58 | } 59 | output MoveToCenter 60 | } 61 | ``` 62 | 2. Build the ML model as an imported concept named ImportedConcept: click `Build ImportedConcept` 63 | 3. Trained the brain: click `Train MoveToCenter` (see below for an expected brain training graph) 64 | ![brain training](training-graph.png) 65 | 66 | ## Pytest 67 | 68 | Run a pytest to import a model, build the concept, train moab with the model as input, use custom assessment, and assert performance from logs from your Log Analytics Workspace. 69 | 70 | ### Prerequisites 71 | 72 | - Bonsai Workspace 73 | - Moab managed sim already in workspace (create a brain using the sample) 74 | - azure cli 75 | - docker 76 | - bonsai-cli 77 | 78 | ```bash 79 | pytest tests/test_model_import.py -s \ 80 | --brain_name \ 81 | --log_analy_workspace \ 82 | --custom_assess_name \ 83 | --model_file_path "./Machine-Teaching-Examples/model_import/state_transform_deep.zip" 84 | 85 | or 86 | --model_file_path "./Machine-Teaching-Examples/model_import/state_transform_deep.onnx" 87 | ``` 88 | 89 | Be sure to use your `workspace id` and not your `workspace name` in your Log Analytics Workspace within Azure Portal. 90 | 91 | ![](LAW.png) -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/environment.yml: -------------------------------------------------------------------------------- 1 | name: model-import 2 | dependencies: 3 | - python=3.7 4 | - pip=19.1.1 5 | - pip: 6 | - microsoft-bonsai-api 7 | - bonsai-cli 8 | - matplotlib 9 | - scipy 10 | - numpy 11 | - tensorflow==1.15.2 12 | - keras2onnx==1.7.0 13 | - cryptography==2.9.2 14 | - keras==2.3.1 15 | - jupyter==1.0.0 16 | - pandas==1.1.2 17 | - azure-loganalytics==0.1.0 18 | - azure-mgmt-loganalytics==0.7.0 19 | - pytest 20 | - azure-cli==2.10.1 -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/imported-concept-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/moabsim-py/54bb72bf47f6c23607f910d0ba352c07f0a7ba73/Machine-Teaching-Examples/model_import/imported-concept-graph.png -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/moab-imported-concept.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab sample illustrating how to import a pre-trained neural network 8 | # model in a Bonsai brain. Use the Jupyter notebook to 9 | # create a dummy deep state transform model in either Tensorflow v1.15.2 10 | # SavedModelBuilder or ONNX format. 11 | 12 | # Use the bonsai-cli to import the model to Bonsai: 13 | 14 | # bonsai importedmodel create 15 | # --name 16 | # --modelfilepath 17 | # --description "state transform NN" 18 | # --display-name 19 | 20 | # Where: 21 | # might be state_transform_deep.zip or state_transform_deep.onnx 22 | # might be "My ML Model" 23 | 24 | ### 25 | 26 | inkling "2.0" 27 | 28 | using Math 29 | using Goal 30 | 31 | # Distances measured in meters 32 | const RadiusOfPlate = 0.1125 # m 33 | 34 | # Velocities measured in meters per sec. 35 | const MaxVelocity = 6.0 36 | const MaxInitialVelocity = 1.0 37 | 38 | # Threshold for ball placement 39 | const CloseEnough = 0.02 40 | 41 | # Default time delta between simulation steps (s) 42 | const DefaultTimeDelta = 0.045 43 | 44 | # Maximum distance per step in meters 45 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 46 | 47 | # State received from the simulator after each iteration 48 | 49 | type ObservableState { 50 | # Ball X,Y position 51 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 52 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 53 | 54 | # Ball X,Y velocity 55 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 56 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 57 | 58 | # Testing added sim observables 59 | estimated_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 60 | estimated_y:number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 61 | } 62 | 63 | # Action provided as output by policy and sent as 64 | # input to the simulator 65 | type SimAction { 66 | # Range -1 to 1 is a scaled value that represents 67 | # the full plate rotation range supported by the hardware. 68 | input_pitch: number<-1 .. 1>, # rotate about x-axis 69 | input_roll: number<-1 .. 1>, # rotate about y-axis 70 | } 71 | 72 | # Per-episode configuration that can be sent to the simulator. 73 | # All iterations within an episode will use the same configuration. 74 | type SimConfig { 75 | # Model initial ball conditions 76 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 77 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 78 | 79 | # Model initial ball velocity conditions 80 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 81 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 82 | 83 | # Range -1 to 1 is a scaled value that represents 84 | # the full plate rotation range supported by the hardware. 85 | initial_pitch: number<-1 .. 1>, 86 | initial_roll: number<-1 .. 1>, 87 | } 88 | 89 | 90 | 91 | # Define a concept graph with a single concept 92 | graph (input: ObservableState) { 93 | 94 | # Add the imported concept by name with the correct type definitions 95 | # - can only have one input 96 | # - cannot use image inputs 97 | # - must have an input with the same dimensions as the Inkling concept 98 | # it maps to 99 | concept ImportedConcept(input): ObservableState { 100 | import {Model: "My ML Model"} 101 | } 102 | 103 | concept MoveToCenter(ImportedConcept): SimAction { 104 | curriculum { 105 | # The source of training for this concept is a simulator that 106 | # - can be configured for each episode using fields defined in SimConfig, 107 | # - accepts per-iteration actions defined in SimAction, and 108 | # - outputs states with the fields defined in SimState. 109 | source simulator MoabSim(Action: SimAction, Config: SimConfig): ObservableState { 110 | # Automatically launch the simulator with this 111 | # registered package name. 112 | package "Moab" 113 | } 114 | 115 | training { 116 | # Limit episodes to 250 iterations instead of the default 1000. 117 | EpisodeIterationLimit: 250 118 | } 119 | 120 | # The objective of training is expressed as a goal with two 121 | # subgoals: don't let the ball fall off the plate, and drive 122 | # the ball to the center of the plate. 123 | goal (State: ObservableState) { 124 | avoid `Fall Off Plate`: 125 | Math.Hypot(State.ball_x, State.ball_y) 126 | in Goal.RangeAbove(RadiusOfPlate * 0.8) 127 | drive `Center Of Plate`: 128 | [State.ball_x, State.ball_y] 129 | in Goal.Sphere([0, 0], CloseEnough) 130 | } 131 | 132 | lesson `Randomize Start` { 133 | # Specify the configuration parameters that should be varied 134 | # from one episode to the next during this lesson. 135 | scenario { 136 | initial_x: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 137 | initial_y: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 138 | 139 | initial_vel_x: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 140 | initial_vel_y: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 141 | 142 | initial_pitch: number<-0.2 .. 0.2>, 143 | initial_roll: number<-0.2 .. 0.2>, 144 | } 145 | } 146 | } 147 | } 148 | output MoveToCenter 149 | } 150 | 151 | # Special string to hook up the simulator visualizer 152 | # in the web interface. 153 | const SimulatorVisualizer = "/moabviz/" 154 | -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/state_transform_deep.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/moabsim-py/54bb72bf47f6c23607f910d0ba352c07f0a7ba73/Machine-Teaching-Examples/model_import/state_transform_deep.onnx -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/state_transform_deep.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/moabsim-py/54bb72bf47f6c23607f910d0ba352c07f0a7ba73/Machine-Teaching-Examples/model_import/state_transform_deep.zip -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/state_transform_keras_model.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "id": "MhoQ0WE77laV" 7 | }, 8 | "source": [ 9 | "## Goal: Create a Keras Machine Learning Model, Save it as either SavedModel or Onnx" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 8, 15 | "metadata": { 16 | "execution": { 17 | "iopub.execute_input": "2021-05-13T01:28:03.750807Z", 18 | "iopub.status.busy": "2021-05-13T01:28:03.750218Z", 19 | "iopub.status.idle": "2021-05-13T01:28:09.430878Z", 20 | "shell.execute_reply": "2021-05-13T01:28:09.431273Z" 21 | }, 22 | "id": "dzLKpmZICaWN" 23 | }, 24 | "outputs": [ 25 | { 26 | "output_type": "stream", 27 | "name": "stdout", 28 | "text": [ 29 | "1.15.2\n" 30 | ] 31 | } 32 | ], 33 | "source": [ 34 | "# TensorFlow and tf.keras\n", 35 | "import tensorflow as tf\n", 36 | "\n", 37 | "# Helper libraries\n", 38 | "import numpy as np\n", 39 | "import matplotlib.pyplot as plt\n", 40 | "\n", 41 | "print(tf.__version__)" 42 | ] 43 | }, 44 | { 45 | "cell_type": "markdown", 46 | "metadata": { 47 | "id": "Gxg1XGm0eOBy" 48 | }, 49 | "source": [ 50 | "## Set up the layers of your keras model\n", 51 | "In this example we setup a deep neural net. We won't seek to train the model.\n" 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": 13, 57 | "metadata": { 58 | "execution": { 59 | "iopub.execute_input": "2021-05-13T01:28:12.959246Z", 60 | "iopub.status.busy": "2021-05-13T01:28:12.958646Z", 61 | "iopub.status.idle": "2021-05-13T01:28:14.576221Z", 62 | "shell.execute_reply": "2021-05-13T01:28:14.576664Z" 63 | }, 64 | "id": "9ODch-OFCaW4" 65 | }, 66 | "outputs": [ 67 | { 68 | "output_type": "stream", 69 | "name": "stdout", 70 | "text": [ 71 | "Model: \"sequential_4\"\n_________________________________________________________________\nLayer (type) Output Shape Param # \n=================================================================\nlambda_4 (Lambda) (None, 6) 0 \n_________________________________________________________________\ndense_12 (Dense) (None, 4) 28 \n_________________________________________________________________\ndense_13 (Dense) (None, 4) 20 \n_________________________________________________________________\ndense_14 (Dense) (None, 6) 30 \n=================================================================\nTotal params: 78\nTrainable params: 78\nNon-trainable params: 0\n_________________________________________________________________\n" 72 | ] 73 | } 74 | ], 75 | "source": [ 76 | "input_shape = (6,)\n", 77 | "model = tf.keras.Sequential([\n", 78 | " tf.keras.Input(shape = input_shape),\n", 79 | " tf.keras.layers.Lambda(lambda x: x*2), #trivial operation multiplying all inputs by a factor 2\n", 80 | " tf.keras.layers.Dense(4), #comment out if you want to build a trivial model that only multiplies input by 2\n", 81 | " tf.keras.layers.Dense(4), #comment out if you want to build a trivial model that only multiplies input by 2\n", 82 | " tf.keras.layers.Dense(6), #comment out if you want to build a trivial model that only multiplies input by 2\n", 83 | "])\n", 84 | "\n", 85 | "model.summary()" 86 | ] 87 | }, 88 | { 89 | "cell_type": "code", 90 | "execution_count": 14, 91 | "metadata": {}, 92 | "outputs": [ 93 | { 94 | "output_type": "execute_result", 95 | "data": { 96 | "text/plain": [ 97 | "(1, 6)" 98 | ] 99 | }, 100 | "metadata": {}, 101 | "execution_count": 14 102 | } 103 | ], 104 | "source": [ 105 | "# creating a test input (ones)\n", 106 | "x = np.array([1,1,1,1,1,1])\n", 107 | "x = np.reshape(x, (1,6))\n", 108 | "x.shape" 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": 15, 114 | "metadata": {}, 115 | "outputs": [ 116 | { 117 | "output_type": "execute_result", 118 | "data": { 119 | "text/plain": [ 120 | "array([[ 0.911648 , 2.2982535 , -0.77847415, -1.4436646 , -1.3750376 ,\n", 121 | " -0.7393315 ]], dtype=float32)" 122 | ] 123 | }, 124 | "metadata": {}, 125 | "execution_count": 15 126 | } 127 | ], 128 | "source": [ 129 | "# testing the model with a test input\n", 130 | "model.predict(x) " 131 | ] 132 | }, 133 | { 134 | "source": [ 135 | "## Save model for bonsai model import\n", 136 | "1. tf saved model format using SavedModelBuilder\n", 137 | "2. onnx saved model\n", 138 | "3. NOT COMPATIBLE with bonsai model import: direct tf SavedModel" 139 | ], 140 | "cell_type": "markdown", 141 | "metadata": {} 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": 16, 146 | "metadata": {}, 147 | "outputs": [ 148 | { 149 | "output_type": "stream", 150 | "name": "stdout", 151 | "text": [ 152 | "builder\n", 153 | "INFO:tensorflow:No assets to save.\n", 154 | "INFO:tensorflow:No assets to write.\n", 155 | "INFO:tensorflow:SavedModel written to: ./state_transform_deep/saved_model.pb\n" 156 | ] 157 | }, 158 | { 159 | "output_type": "execute_result", 160 | "data": { 161 | "text/plain": [ 162 | "b'./state_transform_deep/saved_model.pb'" 163 | ] 164 | }, 165 | "metadata": {}, 166 | "execution_count": 16 167 | } 168 | ], 169 | "source": [ 170 | "#save keras model using SavedModelBuilder: compress as a .zip and ready for bonsai model import\n", 171 | "from keras import backend as K\n", 172 | "import tensorflow as tf\n", 173 | "signature = tf.saved_model.signature_def_utils.predict_signature_def(\n", 174 | " inputs={\"data\": model.input}, outputs={\"out\": model.output}\n", 175 | ")\n", 176 | "MODEL_PATH_SAVEDMODEL = \"./state_transform_deep\"\n", 177 | "builder = tf.saved_model.builder.SavedModelBuilder(MODEL_PATH_SAVEDMODEL)\n", 178 | "print('builder')\n", 179 | "builder.add_meta_graph_and_variables(\n", 180 | " sess=K.get_session(),\n", 181 | " tags=[tf.saved_model.tag_constants.SERVING],\n", 182 | " signature_def_map={\n", 183 | " tf.saved_model.signature_constants.DEFAULT_SERVING_SIGNATURE_DEF_KEY: signature\n", 184 | " },\n", 185 | ")\n", 186 | "builder.save()" 187 | ] 188 | }, 189 | { 190 | "cell_type": "code", 191 | "execution_count": 17, 192 | "metadata": {}, 193 | "outputs": [ 194 | { 195 | "output_type": "stream", 196 | "name": "stderr", 197 | "text": [ 198 | "The ONNX operator number change on the optimization: 14 -> 7\n", 199 | "The maximum opset needed by this model is only 9.\n" 200 | ] 201 | } 202 | ], 203 | "source": [ 204 | "#save keras model as ONNX: ready for bonsai model import\n", 205 | "import os\n", 206 | "os.environ['TF_KERAS'] = '1'\n", 207 | "import keras2onnx\n", 208 | "onnx_model = keras2onnx.convert_keras(model, model.name)\n", 209 | "\n", 210 | "with open(\"state_transform_deep.onnx\", \"wb\") as f:\n", 211 | " f.write(onnx_model.SerializeToString())" 212 | ] 213 | }, 214 | { 215 | "cell_type": "code", 216 | "execution_count": null, 217 | "metadata": {}, 218 | "outputs": [], 219 | "source": [ 220 | "#CAUTION this way of saving tf SavedModel is not be compatible with bonsai model import\n", 221 | "#save keras model as tf savedmodel\n", 222 | "#model.save('saved_model/my_model',save_format='SavedModel')" 223 | ] 224 | } 225 | ], 226 | "metadata": { 227 | "colab": { 228 | "collapsed_sections": [], 229 | "name": "classification.ipynb", 230 | "toc_visible": true 231 | }, 232 | "kernelspec": { 233 | "name": "python3710jvsc74a57bd0e7e0ed120b68561b3ba35405b40e60bc605557aa4fbc88a8a2ecdcf4993dcd76", 234 | "display_name": "Python 3.7.10 64-bit ('model-import': conda)" 235 | }, 236 | "language_info": { 237 | "codemirror_mode": { 238 | "name": "ipython", 239 | "version": 3 240 | }, 241 | "file_extension": ".py", 242 | "mimetype": "text/x-python", 243 | "name": "python", 244 | "nbconvert_exporter": "python", 245 | "pygments_lexer": "ipython3", 246 | "version": "3.7.10" 247 | }, 248 | "metadata": { 249 | "interpreter": { 250 | "hash": "d414d662c8e6e82f50b7fda920b17dedcb1a2bc687fc6867fe60c7d4063d2a99" 251 | } 252 | } 253 | }, 254 | "nbformat": 4, 255 | "nbformat_minor": 0 256 | } -------------------------------------------------------------------------------- /Machine-Teaching-Examples/model_import/training-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/moabsim-py/54bb72bf47f6c23607f910d0ba352c07f0a7ba73/Machine-Teaching-Examples/model_import/training-graph.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | 3 | Simulators need two environment variables set to be able to attach to the platform. 4 | 5 | The first is `SIM_ACCESS_KEY`. You can create one from the `Account Settings` page. 6 | You have one chance to copy the key once it has been created. Make sure you don't enter 7 | the ID. 8 | 9 | The second is `SIM_WORKSPACE`. You can find this in the URL after `/workspaces/` once 10 | you are logged in to the platform. 11 | 12 | There is also an optional `SIM_API_HOST` key, but if it is not set it will default to `https://api.bons.ai`. 13 | 14 | If you're launching your simulator from the command line, make sure that you have these two 15 | environment variables set. If you like, you could use the following example script: 16 | 17 | ```sh 18 | export SIM_WORKSPACE= 19 | export SIM_ACCESS_KEY= 20 | python3 moab_sim.py 21 | ``` 22 | 23 | You will need to install support libraries prior to running. Our demos depend on `bonsai-common`. 24 | This library will need to be installed from source. 25 | 26 | ```sh 27 | pip3 install git+https://github.com/microsoft/bonsai-common 28 | ``` 29 | 30 | ## Building Dockerfile 31 | 32 | docker build -t -f Dockerfile ./ 33 | 34 | ## Run Dockerfile local (optional) 35 | 36 | ``` 37 | docker run --rm -it -e SIM_ACCESS_KEY="" -e SIM_API_HOST="" -e SIM_WORKSPACE="" 38 | ``` 39 | 40 | ## How to push to ACR 41 | 42 | ``` 43 | az login (Is not necessary if you are already up to date or logged in recently) 44 | az acr login --subscription --name 45 | docker tag .azurecr.io/bonsai/ 46 | docker push .azurecr.io/bonsai/ 47 | ``` 48 | 49 | ## Example (Assuming you logged in) 50 | 51 | ``` 52 | docker build -t moab -f Dockerfile ./ 53 | docker tag moab bonsaisimpreprod.azurecr.io/bonsai/moab 54 | docker push bonsaisimpreprod.azurecr.io/bonsai/moab 55 | ``` 56 | 57 | ## Microsoft Open Source Code of Conduct 58 | 59 | This repository is subject to the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct). 60 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets Microsoft's [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /assess_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "recipe": {}, 4 | "episodeConfigurations": [ 5 | {"ball_radius":0.02393318507670268,"initial_roll":-0.1288264770626709,"initial_vel_x":0.16900261387316273,"initial_vel_y":-0.3955991776782724,"initial_x":-0.01210678534894888,"initial_y":0.010198122189413392,"ball_shell":0.00017861655911910286,"initial_pitch":0.05904998014430585}, 6 | {"initial_roll":0.10873720049858092,"initial_vel_x":0.35032984614372253,"initial_vel_y":0.17395760118961334,"initial_x":-0.05847322940826416,"initial_y":0.0018174889264628291,"ball_shell":0.0001879152114270255,"initial_pitch":-0.02228832058608532,"ball_radius":0.018350353464484215} 7 | ] 8 | } -------------------------------------------------------------------------------- /moab.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | echo "Starting multiple moab_sim.py processes..." 3 | parallel -j 7 python3 moab_sim.py ::: {1..7} 4 | -------------------------------------------------------------------------------- /moab_experiment.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2020 Microsoft 5 | 6 | # Moab Experimental 7 | # This inkling is for exposing additional information for future experiments 8 | # for internal developers trying to improve deployments. 9 | # Traditional rewards and terminal conditions can be used if uncommented. 10 | 11 | ### 12 | 13 | inkling "2.0" 14 | 15 | using Math 16 | using Goal 17 | 18 | experiment { 19 | #auto_curriculum: "True", 20 | success_termination_threshold: "0.90", 21 | success_termination_window: "150", 22 | reward_convergence_termination_threshold: "999", 23 | } 24 | 25 | # Time constant per step 26 | const DefaultTimeDelta = 0.045 # s 27 | 28 | # Ping-Pong ball constants 29 | const PingPongRadius = 0.020 # m 30 | const PingPongShell = 0.0002 # m 31 | 32 | # Distances measured in meters 33 | const CloseEnough = 0.02 34 | const RadiusOfPlate = 0.1125 35 | 36 | # Velocities measured in meters per sec. 37 | const MaxVelocity = 3.0 38 | const MaxInitialVelocity = 0.05 39 | 40 | # Noise added to the real ball position to create the estimated ball position 41 | const DefaultBallNoise = 0.000 # m 42 | 43 | # Noise added to the commanded plate position to create the real plate position 44 | const DefaultPlateNoise = (Math.Pi / 180.0) * 0 # rad 45 | 46 | # This is the state received from the simulator 47 | # after each iteration. 48 | type SimState { 49 | # Reflected control parameters that were passed in 50 | pitch: number<-1 .. 1>, 51 | roll: number<-1 .. 1>, 52 | 53 | # Reflected episode config parameters. 54 | # See SimConfig for descriptions and units. 55 | time_delta: number, 56 | plate_theta_vel_limit: number, 57 | plate_theta_acc: number, 58 | plate_theta_limit: number, 59 | ball_noise: number, 60 | plate_noise: number, 61 | 62 | ball_radius: number, 63 | ball_shell: number, 64 | 65 | target_x: number<-RadiusOfPlate .. RadiusOfPlate>, 66 | target_y: number<-RadiusOfPlate .. RadiusOfPlate>, 67 | 68 | # Plate state used for training 69 | plate_theta_x: number, 70 | plate_theta_y: number, 71 | 72 | # Ball modelled state used for rendering 73 | ball_x: number<-RadiusOfPlate .. RadiusOfPlate>, 74 | ball_y: number<-RadiusOfPlate .. RadiusOfPlate>, 75 | 76 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 77 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 78 | 79 | # "Observed" ball position, an emulated estimate of what the camera sees 80 | estimated_x: number<-RadiusOfPlate .. RadiusOfPlate>, 81 | estimated_y: number<-RadiusOfPlate .. RadiusOfPlate>, 82 | 83 | estimated_vel_x: number<-MaxVelocity .. MaxVelocity>, 84 | estimated_vel_y: number<-MaxVelocity .. MaxVelocity>, 85 | 86 | ball_fell_off: number<0, 1,>, 87 | } 88 | 89 | # State that represents the input to the policy 90 | type ObservableState { 91 | # Ball X,Y position, noise applied 92 | ball_x: number<-RadiusOfPlate .. RadiusOfPlate>, 93 | ball_y: number<-RadiusOfPlate .. RadiusOfPlate>, 94 | 95 | # Ball X,Y velocity, noise applied 96 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 97 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 98 | } 99 | 100 | # Action that represents the output of the policy 101 | type SimAction { 102 | input_pitch: number<-1 .. 1>, # scalar over plate rotation about x-axis 103 | input_roll: number<-1 .. 1>, # scalar over plate rotation about y-axis 104 | } 105 | 106 | # Per-episode configuration that can be sent to the simulator. 107 | # All iterations within an episode will use the same configuration. 108 | type SimConfig { 109 | # Model configuration 110 | time_delta: number<0.02 .. 0.05>, # Simulation step time delta in (s) 111 | ball_noise: number, # Noise to add to real ball positions to create estimated positions (mm) 112 | plate_noise: number, # Noise, to add to the commanded plate angles to create actual plate angles (rad) 113 | 114 | ball_radius: number, # Radius of the ball in (m) 115 | ball_shell: number, # Shell thickness of ball in (m), shell>0, shell<=radius 116 | 117 | # Goal that the AI could move the ball towards 118 | target_x: number<-RadiusOfPlate .. RadiusOfPlate>, 119 | target_y: number<-RadiusOfPlate .. RadiusOfPlate>, 120 | 121 | # Model initial ball conditions 122 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 123 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 124 | 125 | # Model initial ball velocity conditions 126 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 127 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 128 | 129 | # Model initial plate conditions 130 | initial_pitch: number<-1 .. 1>, # scalar over full plate rotation range 131 | initial_roll: number<-1 .. 1>, # scalar over full plate rotation range 132 | } 133 | 134 | function TransformState(State: SimState): ObservableState { 135 | # Simulated sensor noise using gaussian 136 | # and skewing of the ball perception with a rotated plate using ray tracing 137 | return { 138 | ball_x: State.ball_x, 139 | ball_y: State.ball_y, 140 | ball_vel_x: State.ball_vel_x, 141 | ball_vel_y: State.ball_vel_y, 142 | } 143 | } 144 | 145 | # Reward function that is evaluated after each iteration 146 | function BalanceBallReward(State: SimState) { 147 | # Return a negative reward if the ball is off the plate 148 | # or we have hit the max iteration count for the episode. 149 | if IsBallOffPlate(State) { 150 | return -10 151 | } 152 | 153 | var DistanceToTarget = GetDistanceToTarget(State) 154 | # Agent is being rewarded based on ground truth state despite noisy sensors 155 | var Speed = Math.Hypot(State.ball_vel_x, State.ball_vel_y) 156 | 157 | if DistanceToTarget < CloseEnough { 158 | return 10 159 | } 160 | 161 | # Shape the reward. 162 | return CloseEnough / DistanceToTarget * 10 163 | } 164 | 165 | # Terminal function that is evaluated after each iteration 166 | function BalanceBallTerminal(State: SimState) { 167 | # We consider it a terminal condition when the ball 168 | # has rolled off the plate or we have hit the maximum 169 | # number of iterations. 170 | return IsBallOffPlate(State) 171 | } 172 | 173 | function IsBallOffPlate(State: SimState) { 174 | return State.ball_fell_off > 0 175 | } 176 | 177 | function GetVectorMagnitude(x: number, y: number) { 178 | return ((x ** 2) + (y ** 2)) ** 0.5 179 | } 180 | 181 | function GetDistanceToTarget(State: SimState) { 182 | var dx = State.ball_x - State.target_x 183 | var dy = State.ball_y - State.target_y 184 | 185 | return GetVectorMagnitude(dx, dy) 186 | } 187 | 188 | graph (input: ObservableState) { 189 | concept MoveToTargetLoc(input): SimAction { 190 | curriculum { 191 | source simulator (Action: SimAction, Config: SimConfig): SimState { 192 | } 193 | 194 | # avoid falling off the plate when the perceived position is near edge of tilted plate 195 | # drive ball to balance in center of plate 196 | goal (State: SimState) { 197 | avoid `Fall Off Plate`: 198 | Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.9) 199 | drive `Center Of Plate`: 200 | [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 201 | } 202 | 203 | # To use reward and terminal functions instead of goals, comment 204 | # out goal statement above and uncomment the following. 205 | #reward BalanceBallReward 206 | #terminal BalanceBallTerminal 207 | 208 | # Specify transform for what info is sent to the AI 209 | state TransformState 210 | 211 | training { 212 | # Limit episodes to 250 iterations instead of the default 1000. 213 | EpisodeIterationLimit: 250, 214 | #TotalIterationLimit: 700000, 215 | #LessonSuccessThreshold: 0.90, 216 | } 217 | 218 | 219 | lesson `Lesson 1` { 220 | scenario { 221 | time_delta: DefaultTimeDelta, 222 | ball_noise: DefaultBallNoise, 223 | plate_noise: DefaultPlateNoise, 224 | 225 | ball_radius: number, 226 | ball_shell: PingPongShell, 227 | 228 | target_x: 0, 229 | target_y: 0, 230 | 231 | initial_x: number<-RadiusOfPlate * 0.636 .. RadiusOfPlate * 0.636>, 232 | initial_y: number<-RadiusOfPlate * 0.636 .. RadiusOfPlate * 0.636>, 233 | 234 | initial_vel_x: number<-0.02 .. 0.02>, 235 | initial_vel_y: number<-0.02 .. 0.02>, 236 | 237 | initial_pitch: number<-0.2 .. 0.2>, 238 | initial_roll: number<-0.2 .. 0.2>, 239 | } 240 | } 241 | } 242 | } 243 | } 244 | 245 | # Special string to hook up the simulator visualizer 246 | # in the web interface. 247 | const SimulatorVisualizer = "/moabviz/" 248 | -------------------------------------------------------------------------------- /moab_interface.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moab-py-v5", 3 | "timeout": 60, 4 | "description": { 5 | "config": { 6 | "category": "Struct", 7 | "fields": [ 8 | { 9 | "name": "initial_pitch", 10 | "type": { 11 | "category": "Number", 12 | "defaultValue": {{initial_pitch}}, 13 | "start": -1, 14 | "stop": 1, 15 | "comment": "Initial plate pitch as a control input [-1..1]" 16 | } 17 | }, 18 | { 19 | "name": "initial_roll", 20 | "type": { 21 | "category": "Number", 22 | "defaultValue": {{initial_roll}}, 23 | "start": -1, 24 | "stop": 1, 25 | "comment": "Initial plate roll as a control input [-1..1]" 26 | } 27 | }, 28 | { 29 | "name": "initial_height_z", 30 | "type": { 31 | "category": "Number", 32 | "defaultValue": {{initial_height_z}}, 33 | "start": -1, 34 | "stop": 1, 35 | "comment": "Initial plate height as a control input [-1..1]" 36 | } 37 | }, 38 | { 39 | "name": "time_delta", 40 | "type": { 41 | "category": "Number", 42 | "defaultValue": {{time_delta}}, 43 | "start": 0.1, 44 | "stop": 0.0083333, 45 | "comment": "Delay between simulation steps (s)" 46 | } 47 | }, 48 | { 49 | "name": "jitter", 50 | "type": { 51 | "category": "Number", 52 | "defaultValue": 0.0, 53 | "start": 0.0, 54 | "stop": 1.0, 55 | "comment": "Step time jitter magnitude (s)" 56 | } 57 | }, 58 | { 59 | "name": "gravity", 60 | "type": { 61 | "category": "Number", 62 | "defaultValue": {{gravity}}, 63 | "comment": "Absolute gravity value (m/s**2)" 64 | } 65 | }, 66 | { 67 | "name": "plate_theta_vel_limit", 68 | "type": { 69 | "category": "Number", 70 | "defaultValue": {{plate_theta_vel_limit}}, 71 | "comment": "Max plate angular velocity (rad/s)" 72 | } 73 | }, 74 | { 75 | "name": "plate_theta_acc", 76 | "type": { 77 | "category": "Number", 78 | "defaultValue": {{plate_theta_acc}}, 79 | "comment": "Plate angular acceleration (rad/s**2)" 80 | } 81 | }, 82 | { 83 | "name": "plate_theta_limit", 84 | "type": { 85 | "category": "Number", 86 | "defaultValue": {{plate_theta_limit}}, 87 | "comment": "Angular rotation limit for plate, [-limit..limit] (rad)" 88 | } 89 | }, 90 | { 91 | "name": "plate_z_limit", 92 | "type": { 93 | "category": "Number", 94 | "defaultValue": {{plate_z_limit}}, 95 | "comment": "Plate Z-translation limit (m)" 96 | } 97 | }, 98 | { 99 | "name": "ball_mass", 100 | "type": { 101 | "category": "Number", 102 | "defaultValue": {{ball_mass}}, 103 | "comment": "Ball mass (kg)" 104 | } 105 | }, 106 | { 107 | "name": "ball_radius", 108 | "type": { 109 | "category": "Number", 110 | "defaultValue": {{ball_radius}}, 111 | "comment": "Ball radius (m)" 112 | } 113 | }, 114 | { 115 | "name": "ball_shell", 116 | "type": { 117 | "category": "Number", 118 | "defaultValue": {{ball_shell}}, 119 | "comment": "Ball shell thickness (m)" 120 | } 121 | }, 122 | { 123 | "name": "obstacle_radius", 124 | "type": { 125 | "category": "Number", 126 | "defaultValue": {{obstacle_radius}}, 127 | "comment": "Obstacle radius (m). If zero, obstacle is disabled" 128 | } 129 | }, 130 | { 131 | "name": "obstacle_x", 132 | "type": { 133 | "category": "Number", 134 | "defaultValue": {{obstacle_x}}, 135 | "comment": "Obstacle X position (m)" 136 | } 137 | }, 138 | { 139 | "name": "obstacle_y", 140 | "type": { 141 | "category": "Number", 142 | "defaultValue": {{obstacle_y}}, 143 | "comment": "Obstacle Y position (m)" 144 | } 145 | }, 146 | { 147 | "name": "target_x", 148 | "type": { 149 | "category": "Number", 150 | "defaultValue": {{target_x}}, 151 | "comment": "X position AI should move ball towards (m)" 152 | } 153 | }, 154 | { 155 | "name": "target_y", 156 | "type": { 157 | "category": "Number", 158 | "defaultValue": {{target_y}}, 159 | "comment": "Y position AI should move ball towards (m)" 160 | } 161 | }, 162 | { 163 | "name": "initial_x", 164 | "type": { 165 | "category": "Number", 166 | "defaultValue": {{initial_x}}, 167 | "comment": "Initial ball X position (m)" 168 | } 169 | }, 170 | { 171 | "name": "initial_y", 172 | "type": { 173 | "category": "Number", 174 | "defaultValue": {{initial_y}}, 175 | "comment": "Initial ball Y position (m)" 176 | } 177 | }, 178 | { 179 | "name": "initial_z", 180 | "type": { 181 | "category": "Number", 182 | "defaultValue": {{initial_z}}, 183 | "comment": "Initial ball Z position (m)" 184 | } 185 | }, 186 | { 187 | "name": "initial_vel_x", 188 | "type": { 189 | "category": "Number", 190 | "defaultValue": {{initial_vel_x}}, 191 | "comment": "Initial ball X velocity (m/s)" 192 | } 193 | }, 194 | { 195 | "name": "initial_vel_y", 196 | "type": { 197 | "category": "Number", 198 | "defaultValue": {{initial_vel_y}}, 199 | "comment": "Initial ball Y velocity (m/s)" 200 | } 201 | }, 202 | { 203 | "name": "initial_vel_z", 204 | "type": { 205 | "category": "Number", 206 | "defaultValue": {{initial_vel_z}}, 207 | "comment": "Initial ball Z velocity (m/s)" 208 | } 209 | }, 210 | { 211 | "name": "initial_speed", 212 | "type": { 213 | "category": "Number", 214 | "defaultValue": {{initial_speed}}, 215 | "comment": "Initial ball speed. Must set initial_direction if used or speed will be 0.0. (m/s)" 216 | } 217 | }, 218 | { 219 | "name": "initial_direction", 220 | "type": { 221 | "category": "Number", 222 | "defaultValue": {{initial_direction}}, 223 | "comment": "Initial ball direction, offset from vector towards target (rad)" 224 | } 225 | }, 226 | { 227 | "name": "ball_noise", 228 | "type": { 229 | "category": "Number", 230 | "defaultValue": {{ball_noise}}, 231 | "comment": "Magnitude of gaussian noise added to ball position in range [-noise, noise] (m)" 232 | } 233 | }, 234 | { 235 | "name": "plate_noise", 236 | "type": { 237 | "category": "Number", 238 | "defaultValue": {{plate_noise}}, 239 | "comment": "Magnitude of gaussian noise added to plate angle in range [-noise, noise] (rad)" 240 | } 241 | } 242 | ] 243 | }, 244 | "action": { 245 | "category": "Struct", 246 | "fields": [ 247 | { 248 | "name": "input_roll", 249 | "type": { 250 | "category": "Number", 251 | "start": -1, 252 | "stop": 1, 253 | "comment": "Commanded roll position" 254 | } 255 | }, 256 | { 257 | "name": "input_pitch", 258 | "type": { 259 | "category": "Number", 260 | "start": -1, 261 | "stop": 1, 262 | "comment": "Commanded pitch control position" 263 | } 264 | }, 265 | { 266 | "name": "input_height_z", 267 | "type": { 268 | "category": "Number", 269 | "start": -1, 270 | "stop": 1, 271 | "comment": "Starting Z plate height control value" 272 | } 273 | } 274 | ] 275 | }, 276 | "state": { 277 | "category": "Struct", 278 | "fields": [ 279 | { 280 | "name": "roll", 281 | "type": { 282 | "category": "Number", 283 | "start": -1, 284 | "stop": 1, 285 | "comment": "Current roll control [-1..1]" 286 | } 287 | }, 288 | { 289 | "name": "pitch", 290 | "type": { 291 | "category": "Number", 292 | "start": -1, 293 | "stop": 1, 294 | "comment": "Current pitch control [-1..1]" 295 | } 296 | }, 297 | { 298 | "name": "height_z", 299 | "type": { 300 | "category": "Number", 301 | "start": -1, 302 | "stop": 1, 303 | "comment": "Current Z plate height control [-1..1]" 304 | } 305 | }, 306 | { 307 | "name": "elapsed_time", 308 | "type": { 309 | "category": "Number", 310 | "comment": "Time since episode start (s)" 311 | } 312 | }, 313 | { 314 | "name": "time_delta", 315 | "type": { 316 | "category": "Number", 317 | "comment": "Current time delta (s)" 318 | } 319 | }, 320 | { 321 | "name": "jitter", 322 | "type": { 323 | "category": "Number", 324 | "comment": "Current time jitter magnitude (s)" 325 | } 326 | }, 327 | { 328 | "name": "step_time", 329 | "type": { 330 | "category": "Number", 331 | "comment": "Actual simulation step time (s). Is time_delta + noise[-jitter, jitter]" 332 | } 333 | }, 334 | { 335 | "name": "gravity", 336 | "type": { 337 | "category": "Number", 338 | "comment": "Current gravity (m/s**2)" 339 | } 340 | }, 341 | { 342 | "name": "plate_radius", 343 | "type": { 344 | "category": "Number", 345 | "comment": "Radius of plate (m)" 346 | } 347 | }, 348 | { 349 | "name": "plate_theta_vel_limit", 350 | "type": { 351 | "category": "Number", 352 | "comment": "Current max angular velocity (rad/s)" 353 | } 354 | }, 355 | { 356 | "name": "plate_theta_acc", 357 | "type": { 358 | "category": "Number", 359 | "comment": "Current angular acceleration (rad/s**2)" 360 | } 361 | }, 362 | { 363 | "name": "plate_theta_limit", 364 | "type": { 365 | "category": "Number", 366 | "comment": "Current angular rotation limit [-limit..limit] (rad)" 367 | } 368 | }, 369 | { 370 | "name": "plate_z_limit", 371 | "type": { 372 | "category": "Number", 373 | "comment": "Current plate z limits [-limit..limit] (m)" 374 | } 375 | }, 376 | { 377 | "name": "ball_mass", 378 | "type": { 379 | "category": "Number", 380 | "comment": "Current ball mass (kg)" 381 | } 382 | }, 383 | { 384 | "name": "ball_radius", 385 | "type": { 386 | "category": "Number", 387 | "comment": "Current ball radius (m)" 388 | } 389 | }, 390 | { 391 | "name": "ball_shell", 392 | "type": { 393 | "category": "Number", 394 | "comment": "Current ball thickness (m)" 395 | } 396 | }, 397 | { 398 | "name": "obstacle_radius", 399 | "type": { 400 | "category": "Number", 401 | "comment": "Obstacle radius (m). If zero, obstacle is disabled" 402 | } 403 | }, 404 | { 405 | "name": "obstacle_x", 406 | "type": { 407 | "category": "Number", 408 | "comment": "Obstacle X position (m)" 409 | } 410 | }, 411 | { 412 | "name": "obstacle_y", 413 | "type": { 414 | "category": "Number", 415 | "comment": "Obstacle Y position (m)" 416 | } 417 | }, 418 | { 419 | "name": "target_x", 420 | "type": { 421 | "category": "Number", 422 | "start": -{{plate_radius}}, 423 | "stop": {{plate_radius}}, 424 | "comment": "Current target X position (m)" 425 | } 426 | }, 427 | { 428 | "name": "target_y", 429 | "type": { 430 | "category": "Number", 431 | "start": -{{plate_radius}}, 432 | "stop": {{plate_radius}}, 433 | "comment": "Current target Y position (m)" 434 | } 435 | }, 436 | { 437 | "name": "plate_x", 438 | "type": { 439 | "category": "Number", 440 | "comment": "Current plate X position (m)" 441 | } 442 | }, 443 | { 444 | "name": "plate_y", 445 | "type": { 446 | "category": "Number", 447 | "comment": "Current plate Y position (m)" 448 | } 449 | }, 450 | { 451 | "name": "plate_z", 452 | "type": { 453 | "category": "Number", 454 | "comment": "Current plate Z position (m)" 455 | } 456 | }, 457 | { 458 | "name": "plate_nor_x", 459 | "type": { 460 | "category": "Number", 461 | "comment": "Current plate X normal (unitless)" 462 | } 463 | }, 464 | { 465 | "name": "plate_nor_y", 466 | "type": { 467 | "category": "Number", 468 | "comment": "Current plate Y normal (unitless)" 469 | } 470 | }, 471 | { 472 | "name": "plate_nor_z", 473 | "type": { 474 | "category": "Number", 475 | "comment": "Current plate Z normal (unitless)" 476 | } 477 | }, 478 | { 479 | "name": "plate_theta_x", 480 | "type": { 481 | "category": "Number", 482 | "comment": "Current plate X theta angle (rad)" 483 | } 484 | }, 485 | { 486 | "name": "plate_theta_y", 487 | "type": { 488 | "category": "Number", 489 | "comment": "Current plate Y theta angle (rad)" 490 | } 491 | }, 492 | { 493 | "name": "plate_theta_vel_x", 494 | "type": { 495 | "category": "Number", 496 | "comment": "Current plate X theta velocity (rad/s)" 497 | } 498 | }, 499 | { 500 | "name": "plate_theta_vel_y", 501 | "type": { 502 | "category": "Number", 503 | "comment": "Current plate Y theta velocity (rad/s)" 504 | } 505 | }, 506 | { 507 | "name": "plate_vel_z", 508 | "type": { 509 | "category": "Number", 510 | "comment": "Current plate Z velocity (m/s)" 511 | } 512 | }, 513 | { 514 | "name": "ball_x", 515 | "type": { 516 | "category": "Number", 517 | "comment": "Current ball X position ground truth (m)" 518 | } 519 | }, 520 | { 521 | "name": "ball_y", 522 | "type": { 523 | "category": "Number", 524 | "comment": "Current ball Y position ground truth (m)" 525 | } 526 | }, 527 | { 528 | "name": "ball_z", 529 | "type": { 530 | "category": "Number", 531 | "comment": "Current ball Z position ground truth (m)" 532 | } 533 | }, 534 | { 535 | "name": "ball_on_plate_x", 536 | "type": { 537 | "category": "Number", 538 | "comment": "Ball X position in plate origin coordinates (m)" 539 | } 540 | }, 541 | { 542 | "name": "ball_on_plate_y", 543 | "type": { 544 | "category": "Number", 545 | "comment": "Ball Y position in plate origin coordinates (m)" 546 | } 547 | }, 548 | { 549 | "name": "ball_vel_x", 550 | "type": { 551 | "category": "Number", 552 | "comment": "Current ball X velocity ground truth (m/s)" 553 | } 554 | }, 555 | { 556 | "name": "ball_vel_y", 557 | "type": { 558 | "category": "Number", 559 | "comment": "Current ball Y velocity ground truth (m/s)" 560 | } 561 | }, 562 | { 563 | "name": "ball_vel_z", 564 | "type": { 565 | "category": "Number", 566 | "comment": "Current ball Z velocity ground truth (m/s)" 567 | } 568 | }, 569 | { 570 | "name": "ball_qat_x", 571 | "type": { 572 | "category": "Number", 573 | "comment": "Current ball rotation quaternion X component" 574 | } 575 | }, 576 | { 577 | "name": "ball_qat_y", 578 | "type": { 579 | "category": "Number", 580 | "comment": "Current ball rotation quaternion Y component" 581 | } 582 | }, 583 | { 584 | "name": "ball_qat_z", 585 | "type": { 586 | "category": "Number", 587 | "comment": "Current ball rotation quaternion Z component" 588 | } 589 | }, 590 | { 591 | "name": "ball_qat_w", 592 | "type": { 593 | "category": "Number", 594 | "comment": "Current ball rotation quaternion W component" 595 | } 596 | }, 597 | { 598 | "name": "obstacle_distance", 599 | "type": { 600 | "category": "Number", 601 | "comment": "Scalar distance between ball and obstacle (m)" 602 | } 603 | }, 604 | { 605 | "name": "obstacle_direction", 606 | "type": { 607 | "category": "Number", 608 | "comment": "Direction to obstacle (rad)" 609 | } 610 | }, 611 | { 612 | "name": "estimated_x", 613 | "type": { 614 | "category": "Number", 615 | "start": -{{plate_radius}}, 616 | "stop": {{plate_radius}}, 617 | "comment": "Estimated ball X position, with ball noise applied (m)" 618 | } 619 | }, 620 | { 621 | "name": "estimated_y", 622 | "type": { 623 | "category": "Number", 624 | "start": -{{plate_radius}}, 625 | "stop": {{plate_radius}}, 626 | "comment": "Estimated ball Y position, with ball noise applied (m)" 627 | } 628 | }, 629 | { 630 | "name": "estimated_radius", 631 | "type": { 632 | "category": "Number", 633 | "start": -{{plate_radius}}, 634 | "stop": {{plate_radius}}, 635 | "comment": "Estimated ball radius, with ball noise applied (m)" 636 | } 637 | }, 638 | { 639 | "name": "estimated_vel_x", 640 | "type": { 641 | "category": "Number", 642 | "start": -1.0, 643 | "stop": 1.0, 644 | "comment": "Estimated ball X velocity, with ball noise applied (m)" 645 | } 646 | }, 647 | { 648 | "name": "estimated_vel_y", 649 | "type": { 650 | "category": "Number", 651 | "start": -1.0, 652 | "stop": 1.0, 653 | "comment": "Estimated ball Y velocity, with ball noise applied (m)" 654 | } 655 | }, 656 | { 657 | "name": "estimated_speed", 658 | "type": { 659 | "category": "Number", 660 | "start": -1.0, 661 | "stop": 1.0, 662 | "comment": "Current estimated ball speed towards target (m/s)" 663 | } 664 | }, 665 | { 666 | "name": "estimated_direction", 667 | "type": { 668 | "category": "Number", 669 | "start": -4, 670 | "stop": 4, 671 | "comment": "Current estimated ball heading towards target in X,Y plane (rad)" 672 | } 673 | }, 674 | { 675 | "name": "estimated_distance", 676 | "type": { 677 | "category": "Number", 678 | "start": 0, 679 | "stop": 1.0, 680 | "comment": "Current distance between ball and target in X,Y plane (m)" 681 | } 682 | }, 683 | { 684 | "name": "ball_noise", 685 | "type": { 686 | "category": "Number", 687 | "comment": "Current ball noise magnitude (m)" 688 | } 689 | }, 690 | { 691 | "name": "plate_noise", 692 | "type": { 693 | "category": "Number", 694 | "comment": "Current plate angle noise magnitude (rad)" 695 | } 696 | }, 697 | { 698 | "name": "ball_fell_off", 699 | "type": { 700 | "category": "Number", 701 | "namedValues": [ 702 | { 703 | "name": "False", 704 | "value": 0 705 | }, 706 | { 707 | "name": "True", 708 | "value": 1 709 | } 710 | ], 711 | "comment": "Ball has fallen off the plate and is unreachable [0,1]" 712 | } 713 | }, 714 | { 715 | "name": "iteration_count", 716 | "type": { 717 | "category": "Number", 718 | "comment": "Current iteration count in this episode" 719 | } 720 | } 721 | ] 722 | } 723 | } 724 | } 725 | -------------------------------------------------------------------------------- /moab_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simulator for the Moab plate+ball balancing device. 3 | """ 4 | __author__ = "Mike Estee" 5 | __copyright__ = "Copyright 2021, Microsoft Corp." 6 | 7 | # pyright: strict 8 | 9 | import math 10 | import random 11 | from typing import Dict, Tuple, cast 12 | 13 | import numpy as np 14 | from pyrr import Quaternion, Vector3, matrix44, quaternion, ray, vector 15 | from pyrr.geometric_tests import ray_intersect_plane 16 | from pyrr.plane import create_from_position 17 | 18 | # Some type aliases for clarity 19 | Plane = np.ndarray 20 | Ray = np.ndarray 21 | 22 | DEFAULT_TIME_DELTA = 0.045 # s, 45ms 23 | DEFAULT_GRAVITY = 9.81 # m/s^2, Earth: there's no place like it. 24 | 25 | DEFAULT_BALL_RADIUS = 0.02 # m, Ping-Pong ball: 20mm 26 | DEFAULT_BALL_SHELL = 0.0002 # m, Ping-Pong ball: 0.2mm 27 | DEFAULT_BALL_MASS = 0.0027 # kg, Ping-Pong ball: 2.7g 28 | 29 | DEFAULT_OBSTACLE_RADIUS = 0.0 # m, if radius is zero, obstacle is disabled 30 | DEFAULT_OBSTACLE_X = 0.03 # m, arbitrarily chosen 31 | DEFAULT_OBSTACLE_Y = 0.03 # m, arbitrarily chosen 32 | 33 | DEFAULT_PLATE_RADIUS = 0.225 / 2.0 # m, Moab: 225mm dia 34 | PLATE_ORIGIN_TO_SURFACE_OFFSET = ( 35 | 0.009 # 9mm offset from plate rot origin to plate surface 36 | ) 37 | 38 | # plate limits 39 | PLATE_HEIGHT_MAX = 0.040 # m, Moab: 40mm 40 | DEFAULT_PLATE_HEIGHT = PLATE_HEIGHT_MAX / 2.0 41 | DEFAULT_PLATE_ANGLE_LIMIT = math.radians(44.0 * 0.5) # rad, 1/2 full range 42 | DEFAULT_PLATE_Z_LIMIT = PLATE_HEIGHT_MAX / 2.0 # m, +/- limit from center Z pos 43 | 44 | # default ball Z position 45 | DEFAULT_BALL_Z_POSITION = ( 46 | DEFAULT_PLATE_HEIGHT + PLATE_ORIGIN_TO_SURFACE_OFFSET + DEFAULT_BALL_RADIUS 47 | ) 48 | 49 | PLATE_MAX_Z_VELOCITY = 1.0 # m/s 50 | PLATE_Z_ACCEL = 10.0 # m/s^2 51 | 52 | # Moab measured velocity at 15deg in 3/60ths, or 300deg/s 53 | DEFAULT_PLATE_MAX_ANGULAR_VELOCITY = (60.0 / 3.0) * math.radians(15) # rad/s 54 | 55 | # Set acceleration to get the plate up to velocity in 1/100th of a sec 56 | DEFAULT_PLATE_ANGULAR_ACCEL = ( 57 | 100.0 / 1.0 58 | ) * DEFAULT_PLATE_MAX_ANGULAR_VELOCITY # rad/s^2 59 | 60 | # useful constants 61 | X_AXIS = np.array([1.0, 0.0, 0.0]) 62 | Y_AXIS = np.array([0.0, 1.0, 0.0]) 63 | Z_AXIS = np.array([0.0, 0.0, 1.0]) 64 | 65 | # Sensor Actuator Noises 66 | DEFAULT_PLATE_NOISE = 0.0 # noise added to plate_theta_* (rad) 67 | DEFAULT_BALL_NOISE = 0.0 # noise added to estimated_* ball location (m) 68 | DEFAULT_JITTER = 0.0 # jitter added to step_time (s) 69 | 70 | 71 | def clamp(val: float, min_val: float, max_val: float): 72 | return min(max_val, max(min_val, val)) 73 | 74 | 75 | class MoabModel: 76 | def __init__(self): 77 | self.reset() 78 | 79 | def reset(self): 80 | """ 81 | Resets the model to known default state. 82 | 83 | If further changes are applied after reseting, the caller should call: 84 | model.update_plate(True) 85 | model.update_ball(True) 86 | """ 87 | # general config 88 | self.time_delta = DEFAULT_TIME_DELTA 89 | self.jitter = DEFAULT_JITTER 90 | self.step_time = self.time_delta 91 | self.elapsed_time = 0.0 92 | self.gravity = DEFAULT_GRAVITY 93 | 94 | # plate config 95 | self.plate_noise = DEFAULT_PLATE_NOISE 96 | self.plate_radius = DEFAULT_PLATE_RADIUS 97 | self.plate_theta_limit = DEFAULT_PLATE_ANGLE_LIMIT 98 | self.plate_theta_vel_limit = DEFAULT_PLATE_MAX_ANGULAR_VELOCITY 99 | self.plate_theta_acc = DEFAULT_PLATE_ANGULAR_ACCEL 100 | self.plate_z_limit = DEFAULT_PLATE_Z_LIMIT 101 | 102 | # ball config 103 | self.ball_noise = DEFAULT_BALL_NOISE 104 | self.ball_mass = DEFAULT_BALL_MASS 105 | self.ball_radius = DEFAULT_BALL_RADIUS 106 | self.ball_shell = DEFAULT_BALL_SHELL 107 | 108 | # control input (unitless) [-1..1] 109 | self.pitch = 0.0 110 | self.roll = 0.0 111 | self.height_z = 0.0 112 | 113 | # plate state 114 | self.plate_theta_x = 0.0 115 | self.plate_theta_y = 0.0 116 | self.plate = Vector3([0.0, 0.0, DEFAULT_PLATE_HEIGHT]) 117 | 118 | self.plate_theta_vel_x = 0.0 119 | self.plate_theta_vel_y = 0.0 120 | self.plate_vel_z = 0.0 121 | 122 | # ball state 123 | self.ball = Vector3([0.0, 0.0, DEFAULT_BALL_Z_POSITION]) 124 | self.ball_vel = Vector3([0.0, 0.0, 0.0]) 125 | self.ball_qat = Quaternion([0.0, 0.0, 0.0, 1.0]) 126 | self.ball_on_plate = Vector3( 127 | [0.0, 0.0, PLATE_ORIGIN_TO_SURFACE_OFFSET + DEFAULT_BALL_RADIUS] 128 | ) 129 | 130 | # current target 131 | self.target_x = 0.0 132 | self.target_y = 0.0 133 | 134 | # current obstacle 135 | self.obstacle_distance = 0.0 136 | self.obstacle_direction = 0.0 137 | self.obstacle_radius = 0.0 138 | self.obstacle_x = 0.0 139 | self.obstacle_y = 0.0 140 | 141 | # camera observed estimated metrics 142 | self.estimated_x = 0.0 143 | self.estimated_y = 0.0 144 | self.estimated_vel_x = 0.0 145 | self.estimated_vel_y = 0.0 146 | self.estimated_radius = self.ball_radius 147 | 148 | # target relative polar coords/vel 149 | self.estimated_speed = 0.0 150 | self.estimated_direction = 0.0 151 | self.estimated_distance = 0.0 152 | 153 | self.prev_estimated_x = 0.0 154 | self.prev_estimated_y = 0.0 155 | 156 | # meta 157 | self.iteration_count = 0 158 | 159 | # now that the base state has been set, run an update 160 | # to make sure the all variables are internally constistent 161 | self.update_plate(True) 162 | self.update_ball(True) 163 | 164 | def halted(self) -> bool: 165 | """ 166 | Returns True if the ball is off the plate. 167 | """ 168 | # ball.z relative to plate 169 | zpos = self.ball.z - ( 170 | self.plate.z + self.ball_radius + PLATE_ORIGIN_TO_SURFACE_OFFSET 171 | ) 172 | 173 | # ball distance from ball position on plate at origin 174 | distance_to_center = math.sqrt( 175 | math.pow(self.ball.x, 2.0) 176 | + math.pow(self.ball.y, 2.0) 177 | + math.pow(zpos, 2.0) 178 | ) 179 | 180 | return distance_to_center > self.plate_radius 181 | 182 | def step(self): 183 | """ 184 | Single step the simulation. 185 | 186 | The current actions will be applied, and the model evaluated. 187 | All state variables will be updated. 188 | """ 189 | self.step_time = self.time_delta + MoabModel.random_noise(self.jitter) 190 | self.elapsed_time += self.step_time 191 | 192 | self.update_plate(False) 193 | self.update_ball(False) 194 | 195 | # update meta 196 | self.iteration_count += 1 197 | 198 | # returns a noise value in the range [-scalar .. scalar] with a gaussian distribution 199 | @staticmethod 200 | def random_noise(scalar: float) -> float: 201 | return scalar * clamp( 202 | random.gauss(mu=0, sigma=0.333), -1, 1 203 | ) # mean zero gauss with sigma = ~sqrt(scalar)/3 204 | 205 | @staticmethod 206 | def accel_param( 207 | q: float, dest: float, vel: float, acc: float, max_vel: float, delta_t: float 208 | ) -> Tuple[float, float]: 209 | """ 210 | perform a linear acceleration of variable towards a destination 211 | with a hard stop at the destination. returns the position and velocity 212 | after delta_t has elapsed. 213 | 214 | q: initial position 215 | dest: target destination 216 | vel: current velocity 217 | acc: acceleration constant 218 | max_vel: maximum velocity 219 | delta_t: time delta 220 | 221 | returns: (final_position, final_velocity) 222 | """ 223 | # direction of accel 224 | dir = 0.0 225 | if q < dest: 226 | dir = 1.0 227 | if q > dest: 228 | dir = -1.0 229 | 230 | # calculate the change in velocity and position 231 | acc = acc * dir * delta_t 232 | vel_end = clamp(vel + acc * delta_t, -max_vel, max_vel) 233 | vel_avg = (vel + vel_end) * 0.5 234 | delta = vel_avg * delta_t 235 | vel = vel_end 236 | 237 | # moving towards the dest? 238 | if (dir > 0 and q < dest and q + delta < dest) or ( 239 | dir < 0 and q > dest and q + delta > dest 240 | ): 241 | q = q + delta 242 | 243 | # stop at dest 244 | else: 245 | q = dest 246 | vel = 0 247 | 248 | return (q, vel) 249 | 250 | @staticmethod 251 | def heading_to_point( 252 | start_x: float, 253 | start_y: float, 254 | vel_x: float, 255 | vel_y: float, 256 | point_x: float, 257 | point_y: float, 258 | ): 259 | """ 260 | Return a heading, in 2D RH coordinate system. 261 | x,y: the current position of the object 262 | vel_x, vel_y: the current velocity vector of motion for the object 263 | point_x, point_y: the destination point to head towards 264 | 265 | returns: offset angle in radians in the range [-pi .. pi] 266 | where: 267 | 0.0: object is moving directly towards the point 268 | [-pi .. <0]: object is moving to the "right" of the point 269 | [>0 .. -pi]: object is moving to the "left" of the point 270 | [-pi, pi]: object is moving directly away from the point 271 | """ 272 | # vector to point 273 | dx = point_x - start_x 274 | dy = point_y - start_y 275 | 276 | # if the ball is already at the target location or 277 | # is not moving, return a heading of 0 so we don't 278 | # attempt to normalize a zero-length vector 279 | if dx == 0 and dy == 0: 280 | return 0 281 | if vel_x == 0 and vel_y == 0: 282 | return 0 283 | 284 | # vectors and lengths 285 | u = vector.normalize([dx, dy, 0.0]) 286 | v = vector.normalize([vel_x, vel_y, 0.0]) 287 | ul = vector.length(u) 288 | vl = vector.length(v) 289 | 290 | # no velocity? already on the target? 291 | angle = 0.0 292 | if (ul != 0.0) and (vl != 0.0): 293 | # angle between vectors 294 | uv_dot = vector.dot(u, v) 295 | 296 | # signed angle 297 | x = u[0] 298 | y = u[1] 299 | angle = math.atan2(vector.dot([-y, x, 0.0], v), uv_dot) 300 | if math.isnan(angle): 301 | angle = 0.0 302 | return angle 303 | 304 | @staticmethod 305 | def distance_to_point(x: float, y: float, point_x: float, point_y: float) -> float: 306 | """ 307 | Return the distance between two 2D points. 308 | """ 309 | dx = point_x - x 310 | dy = point_y - y 311 | return math.sqrt((dx ** 2.0) + (dy ** 2.0)) 312 | 313 | # convert X/Y theta components into a Z-Up RH plane normal 314 | def _plate_nor(self) -> Vector3: 315 | x_rot = matrix44.create_from_axis_rotation( 316 | axis=X_AXIS, theta=self.plate_theta_x 317 | ) 318 | y_rot = matrix44.create_from_axis_rotation( 319 | axis=Y_AXIS, theta=self.plate_theta_y 320 | ) 321 | 322 | # pitch then roll 323 | nor = matrix44.apply_to_vector(mat=x_rot, vec=Z_AXIS) 324 | nor = matrix44.apply_to_vector(mat=y_rot, vec=nor) 325 | nor = vector.normalize(nor) 326 | 327 | return Vector3(nor) 328 | 329 | def update_plate(self, plate_reset: bool = False): 330 | # Find the target xth,yth & zpos 331 | # convert xy[-1..1] to zx[-self.plate_theta_limit .. self.plate_theta_limit] 332 | # convert z[-1..1] to [PLATE_HEIGHT_MAX/2 - self.plate_z_limit .. PLATE_HEIGHT_MAX/2 + self.plate_z_limit] 333 | theta_x_target = self.plate_theta_limit * self.pitch # pitch around X axis 334 | theta_y_target = self.plate_theta_limit * self.roll # roll around Y axis 335 | z_target = (self.height_z * self.plate_z_limit) + PLATE_HEIGHT_MAX / 2.0 336 | 337 | # quantize target positions to whole degree increments 338 | # the Moab hardware can only command by whole degrees 339 | theta_y_target = math.radians(round(math.degrees(theta_y_target))) 340 | theta_x_target = math.radians(round(math.degrees(theta_x_target))) 341 | 342 | # get the current xth,yth & zpos 343 | theta_x, theta_y = self.plate_theta_x, self.plate_theta_y 344 | z_pos = self.plate.z 345 | 346 | # on reset, bypass the motion equations 347 | if plate_reset: 348 | theta_x = theta_x_target 349 | theta_y = theta_y_target 350 | z_pos = z_target 351 | 352 | # smooth transition to target based on accel and velocity limits 353 | else: 354 | theta_x, self.plate_theta_vel_x = MoabModel.accel_param( 355 | theta_x, 356 | theta_x_target, 357 | self.plate_theta_vel_x, 358 | self.plate_theta_acc, 359 | self.plate_theta_vel_limit, 360 | self.step_time, 361 | ) 362 | theta_y, self.plate_theta_vel_y = MoabModel.accel_param( 363 | theta_y, 364 | theta_y_target, 365 | self.plate_theta_vel_y, 366 | self.plate_theta_acc, 367 | self.plate_theta_vel_limit, 368 | self.step_time, 369 | ) 370 | z_pos, self.plate_vel_z = MoabModel.accel_param( 371 | z_pos, 372 | z_target, 373 | self.plate_vel_z, 374 | PLATE_Z_ACCEL, 375 | PLATE_MAX_Z_VELOCITY, 376 | self.step_time, 377 | ) 378 | 379 | # add noise to the plate positions 380 | theta_x += MoabModel.random_noise(self.plate_noise) 381 | theta_y += MoabModel.random_noise(self.plate_noise) 382 | 383 | # clamp to range limits 384 | theta_x = clamp(theta_x, -self.plate_theta_limit, self.plate_theta_limit) 385 | theta_y = clamp(theta_y, -self.plate_theta_limit, self.plate_theta_limit) 386 | z_pos = clamp( 387 | z_pos, 388 | PLATE_HEIGHT_MAX / 2.0 - self.plate_z_limit, 389 | PLATE_HEIGHT_MAX / 2.0 + self.plate_z_limit, 390 | ) 391 | 392 | # Now convert back to plane parameters 393 | self.plate_theta_x = theta_x 394 | self.plate_theta_y = theta_y 395 | self.plate.z = z_pos 396 | 397 | # ball intertia with radius and hollow radius 398 | # I = 2/5 * m * ((r^5 - h^5) / (r^3 - h^3)) 399 | def _ball_inertia(self): 400 | hollow_radius = self.ball_radius - self.ball_shell 401 | return ( 402 | 2.0 403 | / 5.0 404 | * self.ball_mass 405 | * ( 406 | (math.pow(self.ball_radius, 5.0) - math.pow(hollow_radius, 5.0)) 407 | / (math.pow(self.ball_radius, 3.0) - math.pow(hollow_radius, 3.0)) 408 | ) 409 | ) 410 | 411 | def _camera_pos(self) -> Vector3: 412 | """ camera origin (lens center) in world space """ 413 | return Vector3([0.0, 0.0, -0.052]) 414 | 415 | def _update_estimated_ball(self, ball: Vector3): 416 | """ 417 | Ray trace the ball position and an edge of the ball back to the camera 418 | origin and use the collision points with the tilted plate to estimate 419 | what a camera might perceive the ball position and size to be. 420 | """ 421 | # contact ray from camera to plate 422 | camera = self._camera_pos() 423 | displacement = camera - self.ball 424 | displacement_radius = camera - (self.ball + Vector3([self.ball_radius, 0, 0])) 425 | 426 | ball_ray = ray.create(camera, displacement) 427 | ball_radius_ray = ray.create(camera, displacement_radius) 428 | 429 | surface_plane = self._surface_plane() 430 | 431 | contact = Vector3(ray_intersect_plane(ball_ray, surface_plane, False)) 432 | radius_contact = Vector3( 433 | ray_intersect_plane(ball_radius_ray, surface_plane, False) 434 | ) 435 | 436 | x, y = contact.x, contact.y 437 | r = math.fabs(contact.x - radius_contact.x) 438 | 439 | # add the noise in 440 | self.estimated_x = x + MoabModel.random_noise(self.ball_noise) 441 | self.estimated_y = y + MoabModel.random_noise(self.ball_noise) 442 | self.estimated_radius = r + MoabModel.random_noise(self.ball_noise) 443 | 444 | # Use n-1 states to calculate an estimated velocity. 445 | self.estimated_vel_x = ( 446 | self.estimated_x - self.prev_estimated_x 447 | ) / self.step_time 448 | self.estimated_vel_y = ( 449 | self.estimated_y - self.prev_estimated_y 450 | ) / self.step_time 451 | 452 | # distance to target 453 | self.estimated_distance = MoabModel.distance_to_point( 454 | self.estimated_x, self.estimated_y, self.target_x, self.target_y 455 | ) 456 | 457 | # update the derived states 458 | self.estimated_speed = cast( 459 | float, vector.length([self.ball_vel.x, self.ball_vel.y, self.ball_vel.z]) 460 | ) 461 | 462 | self.estimated_direction = MoabModel.heading_to_point( 463 | self.estimated_x, 464 | self.estimated_y, 465 | self.estimated_vel_x, 466 | self.estimated_vel_y, 467 | self.target_x, 468 | self.target_y, 469 | ) 470 | 471 | # update for next time 472 | self.prev_estimated_x = self.estimated_x 473 | self.prev_estimated_y = self.estimated_y 474 | 475 | # update ball position in plate origin coordinates, and obstacle distance and direction 476 | self.ball_on_plate = self.world_to_plate(self.ball.x, self.ball.y, self.ball.z) 477 | self.obstacle_distance = self._get_obstacle_distance() 478 | self.obstacle_direction = MoabModel.heading_to_point( 479 | self.ball.x, 480 | self.ball.y, 481 | self.ball_vel.x, 482 | self.ball_vel.y, 483 | self.obstacle_x, 484 | self.obstacle_y, 485 | ) 486 | 487 | def _get_obstacle_distance(self) -> float: 488 | # Ignore z value, calculate distance between obstacle and ball projection on plate 489 | distance_between_centers = math.sqrt( 490 | math.pow(self.ball_on_plate.x - self.obstacle_x, 2.0) 491 | + math.pow(self.ball_on_plate.y - self.obstacle_y, 2.0) 492 | ) 493 | 494 | # Negative distance to obstacle means the ball and obstacle are overlapping 495 | return distance_between_centers - self.ball_radius - self.obstacle_radius 496 | 497 | def _surface_plane(self) -> Plane: 498 | """ 499 | Return the surface plane of the plate 500 | """ 501 | plate_surface = np.array( 502 | [self.plate.x, self.plate.y, self.plate.z + PLATE_ORIGIN_TO_SURFACE_OFFSET] 503 | ) 504 | return create_from_position(plate_surface, self._plate_nor()) 505 | 506 | def _motion_for_time( 507 | self, u: Vector3, a: Vector3, t: float 508 | ) -> Tuple[Vector3, Vector3]: 509 | """ 510 | Equations of motion for displacement and final velocity 511 | u: initial velocity 512 | a: acceleration 513 | d: displacement 514 | v: final velocity 515 | 516 | d = ut + 1/2at^2 517 | v = u + at 518 | 519 | returns (d, v) 520 | """ 521 | d = (u * t) + (0.5 * a * (t ** 2)) 522 | v = u + a * t 523 | return d, v 524 | 525 | def _update_ball_z(self): 526 | self.ball.z = ( 527 | self.ball.x * math.sin(-self.plate_theta_y) 528 | + self.ball.y * math.sin(self.plate_theta_x) 529 | + self.ball_radius 530 | + self.plate.z 531 | + PLATE_ORIGIN_TO_SURFACE_OFFSET 532 | ) 533 | 534 | def _ball_plate_contact(self, step_t: float) -> float: 535 | # NOTE: the x_theta axis creates motion in the Y-axis, and vice versa 536 | # x_theta, y_theta = self._xy_theta_from_nor(self.plate_nor.xyz) 537 | x_theta = self.plate_theta_x 538 | y_theta = self.plate_theta_y 539 | 540 | # Equations for acceleration on a plate at rest 541 | # accel = (mass * g * theta) / (mass + inertia / radius^2) 542 | # (y_theta,x are intentional swapped here.) 543 | theta = Vector3([y_theta, -x_theta, 0]) 544 | self.ball_acc = ( 545 | theta 546 | / (self.ball_mass + self._ball_inertia() / (self.ball_radius ** 2)) 547 | * self.ball_mass 548 | * self.gravity 549 | ) 550 | 551 | # get contact displacement 552 | disp, vel = self._motion_for_time(self.ball_vel, self.ball_acc, step_t) 553 | 554 | # simplified ball mechanics against a plane 555 | self.ball.x += disp.x 556 | self.ball.y += disp.y 557 | self._update_ball_z() 558 | self.ball_vel = vel 559 | 560 | # For rotation on plate motion we use infinite friction and 561 | # perfect ball / plate coupling. 562 | # Calculate the distance we traveled across the plate during 563 | # this time slice. 564 | rot_distance = math.hypot(disp.x, disp.y) 565 | if rot_distance > 0: 566 | # Calculate the fraction of the circumference that we traveled 567 | # (in radians). 568 | rot_angle = rot_distance / self.ball_radius 569 | 570 | # Create a quaternion that represents the delta rotation for this time period. 571 | # Note that we translate the (x, y) direction into (y, -x) because we're 572 | # creating a vector that represents the axis of rotation which is normal 573 | # to the direction the ball traveled in the x/y plane. 574 | rot_q = quaternion.normalize( 575 | np.array( 576 | [ 577 | disp.y / rot_distance * math.sin(rot_angle / 2.0), 578 | -disp.x / rot_distance * math.sin(rot_angle / 2.0), 579 | 0.0, 580 | math.cos(rot_angle / 2.0), 581 | ] 582 | ) 583 | ) 584 | 585 | old_rot = self.ball_qat.xyzw 586 | new_rot = quaternion.cross(quat1=old_rot, quat2=rot_q) 587 | self.ball_qat.xyzw = quaternion.normalize(new_rot) 588 | return 0.0 589 | 590 | def plate_to_world(self, x: float, y: float, z: float) -> Vector3: 591 | # rotate 592 | x_rot = matrix44.create_from_axis_rotation([1.0, 0.0, 0.0], self.plate_theta_x) 593 | y_rot = matrix44.create_from_axis_rotation([0.0, 1.0, 0.0], self.plate_theta_y) 594 | vec = matrix44.apply_to_vector(mat=x_rot, vec=[x, y, z]) 595 | vec = matrix44.apply_to_vector(mat=y_rot, vec=vec) 596 | 597 | # translate 598 | move = matrix44.create_from_translation( 599 | [self.plate.x, self.plate.y, self.plate.z + PLATE_ORIGIN_TO_SURFACE_OFFSET] 600 | ) 601 | vec = matrix44.apply_to_vector(mat=move, vec=vec) 602 | 603 | return Vector3(vec) 604 | 605 | def world_to_plate(self, x: float, y: float, z: float) -> Vector3: 606 | move = matrix44.create_from_translation( 607 | [ 608 | -self.plate.x, 609 | -self.plate.y, 610 | -(self.plate.z + PLATE_ORIGIN_TO_SURFACE_OFFSET), 611 | ] 612 | ) 613 | vec = matrix44.apply_to_vector(mat=move, vec=[x, y, z]) 614 | 615 | # rotate 616 | x_rot = matrix44.create_from_axis_rotation([1.0, 0.0, 0.0], -self.plate_theta_x) 617 | y_rot = matrix44.create_from_axis_rotation([0.0, 1.0, 0.0], -self.plate_theta_y) 618 | vec = matrix44.apply_to_vector(mat=x_rot, vec=vec) 619 | vec = matrix44.apply_to_vector(mat=y_rot, vec=vec) 620 | 621 | return Vector3(vec) 622 | 623 | def set_initial_ball(self, x: float, y: float, z: float): 624 | self.ball.xyz = [x, y, z] 625 | self._update_ball_z() 626 | 627 | # Set initial observations 628 | self._update_estimated_ball(self.ball) 629 | pass 630 | 631 | def update_ball(self, ball_reset: bool = False): 632 | """ 633 | Update the ball position with the physics model. 634 | """ 635 | if ball_reset: 636 | # this just ensures that the ball is on the plate 637 | self._update_ball_z() 638 | else: 639 | self._ball_plate_contact(self.step_time) 640 | 641 | # Finally, lets make some approximations for observations 642 | self._update_estimated_ball(self.ball) 643 | 644 | def state(self) -> Dict[str, float]: 645 | # x_theta, y_theta = self._xy_theta_from_nor(self.plate_nor) 646 | plate_nor = self._plate_nor() 647 | 648 | return dict( 649 | # reflected input controls 650 | roll=self.roll, 651 | pitch=self.pitch, 652 | height_z=self.height_z, 653 | # reflected constants 654 | time_delta=self.time_delta, 655 | jitter=self.jitter, 656 | step_time=self.step_time, 657 | elapsed_time=self.elapsed_time, 658 | gravity=self.gravity, 659 | plate_radius=self.plate_radius, 660 | plate_theta_vel_limit=self.plate_theta_vel_limit, 661 | plate_theta_acc=self.plate_theta_acc, 662 | plate_theta_limit=self.plate_theta_limit, 663 | plate_z_limit=self.plate_z_limit, 664 | ball_mass=self.ball_mass, 665 | ball_radius=self.ball_radius, 666 | ball_shell=self.ball_shell, 667 | obstacle_radius=self.obstacle_radius, 668 | obstacle_x=self.obstacle_x, 669 | obstacle_y=self.obstacle_y, 670 | target_x=self.target_x, 671 | target_y=self.target_y, 672 | # modelled plate metrics 673 | plate_x=self.plate.x, 674 | plate_y=self.plate.y, 675 | plate_z=self.plate.z, 676 | plate_nor_x=plate_nor.x, 677 | plate_nor_y=plate_nor.y, 678 | plate_nor_z=plate_nor.z, 679 | plate_theta_x=self.plate_theta_x, 680 | plate_theta_y=self.plate_theta_y, 681 | plate_theta_vel_x=self.plate_theta_vel_x, 682 | plate_theta_vel_y=self.plate_theta_vel_y, 683 | plate_vel_z=self.plate_vel_z, 684 | # modelled ball metrics 685 | ball_x=self.ball.x, 686 | ball_y=self.ball.y, 687 | ball_z=self.ball.z, 688 | ball_vel_x=self.ball_vel.x, 689 | ball_vel_y=self.ball_vel.y, 690 | ball_vel_z=self.ball_vel.z, 691 | ball_qat_x=self.ball_qat.x, 692 | ball_qat_y=self.ball_qat.y, 693 | ball_qat_z=self.ball_qat.z, 694 | ball_qat_w=self.ball_qat.w, 695 | ball_on_plate_x=self.ball_on_plate.x, 696 | ball_on_plate_y=self.ball_on_plate.y, 697 | obstacle_distance=self.obstacle_distance, 698 | obstacle_direction=self.obstacle_direction, 699 | # modelled camera observations 700 | estimated_x=self.estimated_x, 701 | estimated_y=self.estimated_y, 702 | estimated_radius=self.estimated_radius, 703 | estimated_vel_x=self.estimated_vel_x, 704 | estimated_vel_y=self.estimated_vel_y, 705 | # modelled positions and velocities 706 | estimated_speed=self.estimated_speed, 707 | estimated_direction=self.estimated_direction, 708 | estimated_distance=self.estimated_distance, 709 | ball_noise=self.ball_noise, 710 | plate_noise=self.plate_noise, 711 | # meta vars 712 | ball_fell_off=1 if self.halted() else 0, 713 | iteration_count=self.iteration_count, 714 | ) 715 | -------------------------------------------------------------------------------- /moab_sim.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simulator for the Moab plate+ball balancing device. 3 | """ 4 | __author__ = "Mike Estee" 5 | __copyright__ = "Copyright 2021, Microsoft Corp." 6 | 7 | # We need to disable a check because the typeshed stubs for jinja are incomplete. 8 | # pyright: strict, reportUnknownMemberType=false 9 | 10 | import logging 11 | import os 12 | import sys 13 | import json 14 | 15 | from jinja2 import Template 16 | from pyrr import matrix33, vector 17 | 18 | from moab_model import MoabModel, clamp 19 | 20 | from bonsai_common import SimulatorSession, Schema 21 | from microsoft_bonsai_api.simulator.generated.models import SimulatorInterface 22 | from microsoft_bonsai_api.simulator.client import BonsaiClientConfig 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | class MoabSim(SimulatorSession): 28 | def __init__(self, config: BonsaiClientConfig): 29 | super().__init__(config) 30 | self.model = MoabModel() 31 | self._episode_count = 0 32 | self.model.reset() 33 | 34 | # callbacks 35 | def halted(self) -> bool: 36 | return self.model.halted() 37 | 38 | def get_interface(self) -> SimulatorInterface: 39 | interface_file_path = os.path.join( 40 | os.path.dirname(os.path.abspath(__file__)), "moab_interface.json" 41 | ) 42 | 43 | # load the template 44 | try: 45 | with open(interface_file_path, "r") as file: 46 | template_str = file.read() 47 | except: 48 | log.info( 49 | "Failed to load interface template file: {}".format(interface_file_path) 50 | ) 51 | raise 52 | 53 | # render the template with our constants 54 | template = Template(template_str) 55 | interface_str = template.render( 56 | initial_pitch=self.model.pitch, 57 | initial_roll=self.model.roll, 58 | initial_height_z=self.model.height_z, 59 | time_delta=self.model.time_delta, 60 | gravity=self.model.time_delta, 61 | plate_radius=self.model.plate_radius, 62 | plate_theta_vel_limit=self.model.plate_theta_vel_limit, 63 | plate_theta_acc=self.model.plate_theta_acc, 64 | plate_theta_limit=self.model.plate_theta_limit, 65 | plate_z_limit=self.model.plate_z_limit, 66 | ball_mass=self.model.ball_mass, 67 | ball_radius=self.model.ball_radius, 68 | ball_shell=self.model.ball_shell, 69 | obstacle_radius=self.model.obstacle_radius, 70 | obstacle_x=self.model.obstacle_x, 71 | obstacle_y=self.model.obstacle_y, 72 | target_x=self.model.target_x, 73 | target_y=self.model.target_y, 74 | initial_x=self.model.ball.x, 75 | initial_y=self.model.ball.y, 76 | initial_z=self.model.ball.z, 77 | initial_vel_x=self.model.ball_vel.x, 78 | initial_vel_y=self.model.ball_vel.y, 79 | initial_vel_z=self.model.ball_vel.z, 80 | initial_speed=0, 81 | initial_direction=0, 82 | ball_noise=self.model.ball_noise, 83 | plate_noise=self.model.plate_noise, 84 | ) 85 | interface = json.loads(interface_str) 86 | return SimulatorInterface( 87 | name=interface["name"], 88 | timeout=interface["timeout"], 89 | simulator_context=self.get_simulator_context(), 90 | description=interface["description"], 91 | ) 92 | 93 | def get_state(self) -> Schema: 94 | return self.model.state() 95 | 96 | def _set_velocity_for_speed_and_direction(self, speed: float, direction: float): 97 | # get the heading 98 | dx = self.model.target_x - self.model.ball.x 99 | dy = self.model.target_y - self.model.ball.y 100 | 101 | # direction is meaningless if we're already at the target 102 | if (dx != 0) or (dy != 0): 103 | 104 | # set the magnitude 105 | vel = vector.set_length([dx, dy, 0.0], speed) 106 | 107 | # rotate by direction around Z-axis at ball position 108 | rot = matrix33.create_from_axis_rotation([0.0, 0.0, 1.0], direction) 109 | vel = matrix33.apply_to_vector(rot, vel) 110 | 111 | # unpack into ball velocity 112 | self.model.ball_vel.x = vel[0] 113 | self.model.ball_vel.y = vel[1] 114 | self.model.ball_vel.z = vel[2] 115 | 116 | def episode_start(self, config: Schema) -> None: 117 | # return to known good state to avoid accidental episode-episode dependencies 118 | self.model.reset() 119 | 120 | # initial control state. these are all [-1..1] unitless 121 | self.model.roll = config.get("initial_roll", self.model.roll) 122 | self.model.pitch = config.get("initial_pitch", self.model.pitch) 123 | 124 | self.model.height_z = config.get("initial_height_z", self.model.height_z) 125 | 126 | # constants, SI units. 127 | self.model.time_delta = config.get("time_delta", self.model.time_delta) 128 | self.model.jitter = config.get("jitter", self.model.jitter) 129 | self.model.gravity = config.get("gravity", self.model.gravity) 130 | self.model.plate_theta_vel_limit = config.get( 131 | "plate_theta_vel_limit", self.model.plate_theta_vel_limit 132 | ) 133 | self.model.plate_theta_acc = config.get( 134 | "plate_theta_acc", self.model.plate_theta_acc 135 | ) 136 | self.model.plate_theta_limit = config.get( 137 | "plate_theta_limit", self.model.plate_theta_limit 138 | ) 139 | self.model.plate_z_limit = config.get("plate_z_limit", self.model.plate_z_limit) 140 | 141 | self.model.ball_mass = config.get("ball_mass", self.model.ball_mass) 142 | self.model.ball_radius = config.get("ball_radius", self.model.ball_radius) 143 | self.model.ball_shell = config.get("ball_shell", self.model.ball_shell) 144 | 145 | self.model.obstacle_radius = config.get( 146 | "obstacle_radius", self.model.obstacle_radius 147 | ) 148 | self.model.obstacle_x = config.get("obstacle_x", self.model.obstacle_x) 149 | self.model.obstacle_y = config.get("obstacle_y", self.model.obstacle_y) 150 | 151 | # a target position the AI can try and move the ball to 152 | self.model.target_x = config.get("target_x", self.model.target_x) 153 | self.model.target_y = config.get("target_y", self.model.target_y) 154 | 155 | # observation config 156 | self.model.ball_noise = config.get("ball_noise", self.model.ball_noise) 157 | self.model.plate_noise = config.get("plate_noise", self.model.plate_noise) 158 | 159 | # now we can update the initial plate metrics from the constants and the controls 160 | self.model.update_plate(plate_reset=True) 161 | 162 | # initial ball state after updating plate 163 | self.model.set_initial_ball( 164 | config.get("initial_x", self.model.ball.x), 165 | config.get("initial_y", self.model.ball.y), 166 | config.get("initial_z", self.model.ball.z), 167 | ) 168 | 169 | # velocity set as a vector 170 | self.model.ball_vel.x = config.get("initial_vel_x", self.model.ball_vel.x) 171 | self.model.ball_vel.y = config.get("initial_vel_y", self.model.ball_vel.y) 172 | self.model.ball_vel.z = config.get("initial_vel_z", self.model.ball_vel.z) 173 | 174 | # velocity set as a speed/direction towards target 175 | initial_speed = config.get("initial_speed", None) 176 | initial_direction = config.get("initial_direction", None) 177 | if initial_speed is not None and initial_direction is not None: 178 | self._set_velocity_for_speed_and_direction(initial_speed, initial_direction) 179 | 180 | # new episode, iteration count reset 181 | self.iteration_count = 0 182 | self._episode_count += 1 183 | 184 | def episode_step(self, action: Schema): 185 | # use new syntax or fall back to old parameter names 186 | self.model.roll = action.get("input_roll", self.model.roll) 187 | self.model.pitch = action.get("input_pitch", self.model.pitch) 188 | 189 | # clamp inputs to legal ranges 190 | self.model.roll = clamp(self.model.roll, -1.0, 1.0) 191 | self.model.pitch = clamp(self.model.pitch, -1.0, 1.0) 192 | 193 | self.model.height_z = clamp( 194 | action.get("input_height_z", self.model.height_z), -1.0, 1.0 195 | ) 196 | 197 | self.model.step() 198 | 199 | self.iteration_count += 1 200 | 201 | def episode_finish(self, reason: str): 202 | # log ball's distance to center and velocity at the end of each episode. 203 | log.info( 204 | "Episode {} ends at iter {}, ball dist to target ={}, ball speed={} reason={}".format( 205 | self._episode_count, 206 | self.iteration_count, 207 | self.model.estimated_distance, 208 | self.model.estimated_speed, 209 | reason, 210 | ) 211 | ) 212 | 213 | 214 | if __name__ == "__main__": 215 | try: 216 | # configuration for talking to server 217 | config = BonsaiClientConfig(argv=sys.argv) 218 | sim = MoabSim(config) 219 | sim.model.reset() 220 | while sim.run(): 221 | continue 222 | 223 | except Exception as e: 224 | print(e) 225 | -------------------------------------------------------------------------------- /moab_tutorial_1.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab Tutorial 1 8 | # This introductory sample demonstrates how to teach a policy for 9 | # controlling a ball on the plate of a "Moab" hardware device. 10 | 11 | # To understand this Inkling better, please follow our tutorial walkthrough: 12 | # https://aka.ms/moab/tutorial1 13 | 14 | ### 15 | 16 | inkling "2.0" 17 | 18 | using Math 19 | using Goal 20 | 21 | # Distances measured in meters 22 | const RadiusOfPlate = 0.1125 # m 23 | 24 | # Velocities measured in meters per sec. 25 | const MaxVelocity = 6.0 26 | const MaxInitialVelocity = 1.0 27 | 28 | # Threshold for ball placement 29 | const CloseEnough = 0.02 30 | 31 | # Default time delta between simulation steps (s) 32 | const DefaultTimeDelta = 0.045 33 | 34 | # Maximum distance per step in meters 35 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 36 | 37 | # State received from the simulator after each iteration 38 | type ObservableState { 39 | # Ball X,Y position 40 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 41 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 42 | 43 | # Ball X,Y velocity 44 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 45 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 46 | } 47 | 48 | # Action provided as output by policy and sent as 49 | # input to the simulator 50 | type SimAction { 51 | # Range -1 to 1 is a scaled value that represents 52 | # the full plate rotation range supported by the hardware. 53 | input_pitch: number<-1 .. 1>, # rotate about x-axis 54 | input_roll: number<-1 .. 1>, # rotate about y-axis 55 | } 56 | 57 | # Per-episode configuration that can be sent to the simulator. 58 | # All iterations within an episode will use the same configuration. 59 | type SimConfig { 60 | # Model initial ball conditions 61 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 62 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 63 | 64 | # Model initial ball velocity conditions 65 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 66 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 67 | 68 | # Range -1 to 1 is a scaled value that represents 69 | # the full plate rotation range supported by the hardware. 70 | initial_pitch: number<-1 .. 1>, 71 | initial_roll: number<-1 .. 1>, 72 | } 73 | 74 | # Define a concept graph with a single concept 75 | graph (input: ObservableState) { 76 | concept MoveToCenter(input): SimAction { 77 | curriculum { 78 | # The source of training for this concept is a simulator that 79 | # - can be configured for each episode using fields defined in SimConfig, 80 | # - accepts per-iteration actions defined in SimAction, and 81 | # - outputs states with the fields defined in SimState. 82 | source simulator MoabSim (Action: SimAction, Config: SimConfig): ObservableState { 83 | } 84 | 85 | # The objective of training is expressed as a goal with two 86 | # subgoals: don't let the ball fall off the plate, and drive 87 | # the ball to the center of the plate. 88 | goal (State: ObservableState) { 89 | avoid `Fall Off Plate`: 90 | Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.8) 91 | drive `Center Of Plate`: 92 | [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 93 | } 94 | 95 | training { 96 | # Limit episodes to 250 iterations instead of the default 1000. 97 | EpisodeIterationLimit: 250 98 | } 99 | 100 | lesson `Randomize Start` { 101 | # Specify the configuration parameters that should be varied 102 | # from one episode to the next during this lesson. 103 | scenario { 104 | initial_x: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 105 | initial_y: number<-RadiusOfPlate * 0.5 .. RadiusOfPlate * 0.5>, 106 | 107 | initial_vel_x: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 108 | initial_vel_y: number<-MaxInitialVelocity * 0.02 .. MaxInitialVelocity * 0.02>, 109 | 110 | initial_pitch: number<-0.2 .. 0.2>, 111 | initial_roll: number<-0.2 .. 0.2>, 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | # Special string to hook up the simulator visualizer 119 | # in the web interface. 120 | const SimulatorVisualizer = "/moabviz/" -------------------------------------------------------------------------------- /moab_tutorial_2.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab Tutorial 2 8 | # This sample demonstrates how to use domain randomization of 9 | # the ball radius to achieve better deployment on hardware. 10 | 11 | # To understand this Inkling better, please follow our tutorial walkthrough: 12 | # https://aka.ms/moab/tutorial2 13 | 14 | ### 15 | 16 | inkling "2.0" 17 | 18 | using Math 19 | using Goal 20 | 21 | # Distances measured in meters 22 | const RadiusOfPlate = 0.1125 23 | 24 | # Velocities measured in meters per sec. 25 | const MaxVelocity = 6.0 26 | const MaxInitialVelocity = 1.0 27 | 28 | # Threshold for ball placement 29 | const CloseEnough = 0.02 30 | 31 | # Default time delta between simulation steps (s) 32 | const DefaultTimeDelta = 0.045 33 | 34 | # Maximum distance per step in meters 35 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 36 | 37 | # Ping-Pong ball constants 38 | const PingPongRadius = 0.020 # m 39 | const PingPongShell = 0.0002 # m 40 | 41 | # State received from the simulator after each iteration 42 | type ObservableState { 43 | # Ball X,Y position 44 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 45 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 46 | 47 | # Ball X,Y velocity 48 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 49 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 50 | } 51 | 52 | # Action provided as output by policy and sent as 53 | # input to the simulator 54 | type SimAction { 55 | # Range -1 to 1 is a scaled value that represents 56 | # the full plate rotation range supported by the hardware. 57 | input_pitch: number<-1 .. 1>, # scalar over plate rotation about x-axis 58 | input_roll: number<-1 .. 1>, # scalar over plate rotation about y-axis 59 | } 60 | 61 | # Per-episode configuration that can be sent to the simulator. 62 | # All iterations within an episode will use the same configuration. 63 | type SimConfig { 64 | # Model initial ball conditions 65 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, # in (m) 66 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 67 | 68 | # Model initial ball velocity conditions 69 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 70 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 71 | 72 | # Range -1 to 1 is a scaled value that represents 73 | # the full plate rotation range supported by the hardware. 74 | initial_pitch: number<-1 .. 1>, 75 | initial_roll: number<-1 .. 1>, 76 | 77 | # Model configuration 78 | ball_radius: number, # Radius of the ball in (m) 79 | ball_shell: number, # Shell thickness of ball in (m), shell>0, shell<=radius 80 | } 81 | 82 | # Define a concept graph with a single concept 83 | graph (input: ObservableState) { 84 | concept MoveToCenter(input): SimAction { 85 | curriculum { 86 | # The source of training for this concept is a simulator that 87 | # - can be configured for each episode using fields defined in SimConfig, 88 | # - accepts per-iteration actions defined in SimAction, and 89 | # - outputs states with the fields defined in SimState. 90 | source simulator MoabSim (Action: SimAction, Config: SimConfig): ObservableState { 91 | } 92 | 93 | # The objective of training is expressed as a goal with two 94 | # subgoals: don't let the ball fall off the plate, and drive 95 | # the ball to the center of the plate. 96 | goal (State: ObservableState) { 97 | avoid `Fall Off Plate`: 98 | Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.9) 99 | drive `Center Of Plate`: 100 | [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 101 | } 102 | 103 | training { 104 | # Limit episodes to 250 iterations instead of the default 1000. 105 | EpisodeIterationLimit: 250, 106 | } 107 | 108 | lesson `Domain Randomize` { 109 | # Specify the configuration parameters that should be varied 110 | # from one episode to the next during this lesson. 111 | scenario { 112 | # Configure the initial positions within a reasonable effective radius 113 | initial_x: number<-RadiusOfPlate * 0.6 .. RadiusOfPlate * 0.6>, 114 | initial_y: number<-RadiusOfPlate * 0.6 .. RadiusOfPlate * 0.6>, 115 | 116 | # Configure the initial velocities of the ball 117 | initial_vel_x: number<-MaxInitialVelocity * 0.4 .. MaxInitialVelocity * 0.4>, 118 | initial_vel_y: number<-MaxInitialVelocity * 0.4 .. MaxInitialVelocity * 0.4>, 119 | 120 | # Configure the initial plate angles 121 | initial_pitch: number<-0.2 .. 0.2>, 122 | initial_roll: number<-0.2 .. 0.2>, 123 | 124 | # Domain randomize the ping pong ball parameters 125 | ball_radius: number, 126 | ball_shell: number, 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | # Special string to hook up the simulator visualizer 134 | # in the web interface. 135 | const SimulatorVisualizer = "/moabviz/" -------------------------------------------------------------------------------- /moab_tutorial_3.ink: -------------------------------------------------------------------------------- 1 | ### 2 | 3 | # MSFT Bonsai 4 | # Copyright 2021 Microsoft 5 | # This code is licensed under MIT license (see LICENSE for details) 6 | 7 | # Moab Tutorial 3 8 | # This sample demonstrates teaching an AI to avoid an obstacle 9 | # while balancing the ball in the center of the plate. 10 | # Obstacles are defined as an x, y coordinate with a radius. 11 | 12 | # To understand this Inkling better, please follow our tutorial walkthrough: 13 | # https://aka.ms/moab/tutorial3 14 | 15 | ### 16 | 17 | inkling "2.0" 18 | 19 | using Math 20 | using Goal 21 | 22 | # Distances measured in meters 23 | const RadiusOfPlate = 0.1125 # m 24 | 25 | # Velocities measured in meters per sec. 26 | const MaxVelocity = 6.0 27 | const MaxInitialVelocity = 1.0 28 | 29 | # Threshold for ball placement 30 | const CloseEnough = 0.02 31 | 32 | # Default time delta between simulation steps (s) 33 | const DefaultTimeDelta = 0.045 34 | 35 | # Maximum distance per step in meters 36 | const MaxDistancePerStep = DefaultTimeDelta * MaxVelocity 37 | 38 | # Cushion value in avoiding obstacle 39 | const Cushion = 0.01 40 | 41 | # Ping-Pong ball constants 42 | const PingPongRadius = 0.020 # m 43 | const PingPongShell = 0.0002 # m 44 | 45 | # Obstacle definitions 46 | const ObstacleRadius = 0.01 47 | const ObstacleLocationX = 0.04 48 | const ObstacleLocationY = 0.04 49 | 50 | # This is the state received from the simulator 51 | # after each iteration. 52 | type SimState { 53 | # Ball X,Y position 54 | ball_x: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 55 | ball_y: number<-MaxDistancePerStep - RadiusOfPlate .. RadiusOfPlate + MaxDistancePerStep>, 56 | 57 | # Ball X,Y velocity 58 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 59 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 60 | 61 | # Obstacle data 62 | obstacle_direction: number<-Math.Pi .. Math.Pi>, 63 | obstacle_distance: number<-2.0*RadiusOfPlate .. 2.0*RadiusOfPlate>, 64 | } 65 | 66 | # This is the state sent to the brain as it observed 67 | # after each iteration. 68 | type ObservableState { 69 | # Ball X,Y position 70 | ball_x: number<-RadiusOfPlate .. RadiusOfPlate>, 71 | ball_y: number<-RadiusOfPlate .. RadiusOfPlate>, 72 | 73 | # Ball X,Y velocity 74 | ball_vel_x: number<-MaxVelocity .. MaxVelocity>, 75 | ball_vel_y: number<-MaxVelocity .. MaxVelocity>, 76 | } 77 | 78 | # Action provided as output by policy (and sent as 79 | # input to the simulator) 80 | type SimAction { 81 | # Range -1 to 1 is a scaled value that represents 82 | # the full plate rotation range supported by the hardware. 83 | input_pitch: number<-1 .. 1>, # rotate about x-axis 84 | input_roll: number<-1 .. 1>, # rotate about y-axis 85 | } 86 | 87 | # Per-episode configuration that can be sent to the simulator. 88 | # All iterations within an episode will use the same configuration. 89 | type SimConfig { 90 | initial_x: number<-RadiusOfPlate .. RadiusOfPlate>, 91 | initial_y: number<-RadiusOfPlate .. RadiusOfPlate>, 92 | 93 | initial_vel_x: number<-MaxInitialVelocity .. MaxInitialVelocity>, # in (m/s) 94 | initial_vel_y: number<-MaxInitialVelocity .. MaxInitialVelocity>, 95 | 96 | # Range -1 to 1 is a scaled value that represents 97 | # the full plate rotation range supported by the hardware. 98 | initial_pitch: number<-1 .. 1>, 99 | initial_roll: number<-1 .. 1>, 100 | 101 | # Model configuration 102 | ball_radius: number, # Radius of the ball in (m) 103 | ball_shell: number, # Shell thickness of ball in (m), shell>0, shell<=radius 104 | 105 | # Obstacle configuration 106 | obstacle_radius: number<0.0 .. RadiusOfPlate>, 107 | obstacle_x: number<-RadiusOfPlate .. RadiusOfPlate>, 108 | obstacle_y: number<-RadiusOfPlate .. RadiusOfPlate>, 109 | } 110 | 111 | # Define a concept graph with a single concept 112 | graph (input: ObservableState) { 113 | concept MoveToCenter(input): SimAction { 114 | curriculum { 115 | # The source of training for this concept is a simulator that 116 | # - can be configured for each episode using fields defined in SimConfig, 117 | # - accepts per-iteration actions defined in SimAction, and 118 | # - outputs states with the fields defined in SimState. 119 | source simulator MoabSim (Action: SimAction, Config: SimConfig): SimState { 120 | } 121 | 122 | # The objective of training is expressed as a goal with three 123 | # subgoals: don't let the ball fall off the plate, drive 124 | # the ball to the center of the plate, and avoid obstacle 125 | goal (State: SimState) { 126 | avoid FallOffPlate: 127 | Math.Hypot(State.ball_x, State.ball_y) in Goal.RangeAbove(RadiusOfPlate * 0.9) 128 | drive CenterOfPlate: 129 | [State.ball_x, State.ball_y] in Goal.Sphere([0, 0], CloseEnough) 130 | avoid HitObstacle: 131 | State.obstacle_distance in Goal.RangeBelow(Cushion) 132 | } 133 | 134 | training { 135 | # Limit episodes to 250 iterations instead of the default 1000. 136 | EpisodeIterationLimit: 250 137 | } 138 | 139 | lesson `Balance with Constraint` { 140 | # Specify the configuration parameters that should be varied 141 | # from one episode to the next during this lesson. 142 | scenario { 143 | # Configure the initial positions within a reasonable effective radius 144 | initial_x: number<-RadiusOfPlate * 0.6 .. RadiusOfPlate * 0.6>, 145 | initial_y: number<-RadiusOfPlate * 0.6 .. RadiusOfPlate * 0.6>, 146 | 147 | # Configure the initial velocities of the ball 148 | initial_vel_x: number<-MaxInitialVelocity * 0.4 .. MaxInitialVelocity * 0.4>, 149 | initial_vel_y: number<-MaxInitialVelocity * 0.4 .. MaxInitialVelocity * 0.4>, 150 | 151 | # Configure the initial plate angles 152 | initial_pitch: number<-0.2 .. 0.2>, 153 | initial_roll: number<-0.2 .. 0.2>, 154 | 155 | # Domain randomize the ping pong ball parameters 156 | ball_radius: number, 157 | ball_shell: number, 158 | 159 | # Configure obstacle parameters 160 | obstacle_radius: ObstacleRadius, 161 | obstacle_x: ObstacleLocationX, 162 | obstacle_y: ObstacleLocationY, 163 | 164 | } 165 | } 166 | } 167 | } 168 | } 169 | 170 | # Special string to hook up the simulator visualizer 171 | # in the web interface. 172 | const SimulatorVisualizer = "/moabviz/" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jinja2>=2.11 2 | numpy>=1.15.1 3 | pyrr>=0.10.3 4 | -------------------------------------------------------------------------------- /test_moab_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for Moab physics model 3 | """ 4 | __copyright__ = "Copyright 2020, Microsoft Corp." 5 | 6 | # pyright: strict 7 | 8 | import math 9 | 10 | from pyrr import Vector3, vector 11 | 12 | from moab_model import MoabModel 13 | 14 | model = MoabModel() 15 | 16 | 17 | def run_for_duration(duration: float): 18 | """ Runs the model without actions for a duration """ 19 | # sync the plate position with commanded position 20 | model.update_plate(True) 21 | 22 | # run for duration 23 | elapsed = 0.0 # type: float 24 | while elapsed < duration: 25 | model.step() 26 | elapsed += model.step_time 27 | 28 | 29 | # basic test for heading 30 | def test_heading(): 31 | heading = MoabModel.heading_to_point(0.0, 0.0, 1.0, 0.0, 1.0, 0.0) 32 | assert heading == 0.0, "Expected heading to be 0.0 while moving towards point" 33 | 34 | heading = MoabModel.heading_to_point(0.0, 0.0, -1.0, 0.0, 1.0, 0.0) 35 | assert ( 36 | heading == math.pi 37 | ), "Expected heading to be math.pi while moving away from point" 38 | 39 | heading = MoabModel.heading_to_point(0.0, 0.0, 0.0, -1.0, 1.0, 0.0) 40 | assert ( 41 | heading == -math.pi / 2 42 | ), "Expected heading to be negative while moving to right of point" 43 | 44 | heading = MoabModel.heading_to_point(0.0, 0.0, 0.0, 1.0, 1.0, 0.0) 45 | assert ( 46 | heading == math.pi / 2 47 | ), "Expected heading to be positive while moving to left of point" 48 | 49 | 50 | """ 51 | Roll tests. 52 | 53 | The start the ball at the center of the plate and then 54 | command the plate to a tilt position and test the ball 55 | position after 1 second. The ball should be in a known location. 56 | 57 | This tests for: 58 | - sign inversions on the axis 59 | - gravity or other mass related constants being off 60 | - differences in axis behavior 61 | """ 62 | 63 | # constants for roll tests 64 | ROLL_DIST = 0.1105 65 | ROLL_DIST_LO = ROLL_DIST - 0.0001 66 | ROLL_DIST_HI = ROLL_DIST + 0.0001 67 | TILT = 0.1 68 | 69 | # disable the noise for the unit tests 70 | def model_init(model: MoabModel): 71 | model.plate_noise = 0.0 72 | model.ball_noise = 0.0 73 | model.jitter = 0.0 74 | 75 | 76 | def test_roll_right(): 77 | model.reset() 78 | model_init(model) 79 | model.roll = TILT 80 | run_for_duration(1.0) 81 | q = model.ball.x 82 | assert q > ROLL_DIST_LO and q < ROLL_DIST_HI 83 | 84 | 85 | def test_roll_left(): 86 | model.reset() 87 | model_init(model) 88 | model.roll = -TILT 89 | run_for_duration(1.0) 90 | q = model.ball.x 91 | assert -q > ROLL_DIST_LO and -q < ROLL_DIST_HI 92 | 93 | 94 | def test_roll_back(): 95 | model.reset() 96 | model_init(model) 97 | model.pitch = -TILT 98 | run_for_duration(1.0) 99 | q = model.ball.y 100 | assert q > ROLL_DIST_LO and q < ROLL_DIST_HI 101 | 102 | 103 | def test_roll_front(): 104 | model.reset() 105 | model_init(model) 106 | model.pitch = TILT 107 | run_for_duration(1.0) 108 | q = model.ball.y 109 | assert -q > ROLL_DIST_LO and -q < ROLL_DIST_HI 110 | 111 | 112 | """ 113 | Tilt tests. 114 | 115 | These test that the command pitch/roll values move the plate to the limits. 116 | """ 117 | 118 | 119 | def test_pitch(): 120 | model.reset() 121 | model_init(model) 122 | model.pitch = 1.0 123 | run_for_duration(1.0) 124 | assert model.plate_theta_x == model.plate_theta_limit 125 | 126 | model.reset() 127 | model_init(model) 128 | model.pitch = -1.0 129 | run_for_duration(1.0) 130 | assert model.plate_theta_x == -model.plate_theta_limit 131 | 132 | 133 | def test_roll(): 134 | model.reset() 135 | model_init(model) 136 | model.roll = 1.0 137 | run_for_duration(1.0) 138 | assert model.plate_theta_y == model.plate_theta_limit 139 | 140 | model.reset() 141 | model_init(model) 142 | model.roll = -1.0 143 | run_for_duration(1.0) 144 | assert model.plate_theta_y == -model.plate_theta_limit 145 | 146 | 147 | """ 148 | Coordinate origin transform tests. 149 | 150 | These test that transforming between coordinate systems and back yields the original coordinates. 151 | """ 152 | test_vector = Vector3([0.015, 0.020, 0.030]) 153 | TOLERANCE = 0.0000000001 154 | 155 | 156 | def test_world_to_plate_to_world(): 157 | vec_plate = model.world_to_plate(test_vector.x, test_vector.y, test_vector.z) 158 | result = model.plate_to_world(vec_plate.x, vec_plate.y, vec_plate.z) 159 | delta = vector.length(result - test_vector) 160 | assert delta < TOLERANCE 161 | 162 | 163 | def test_plate_to_world_to_plate(): 164 | vec_world = model.plate_to_world(test_vector.x, test_vector.y, test_vector.z) 165 | result = model.world_to_plate(vec_world.x, vec_world.y, vec_world.z) 166 | delta = vector.length(result - test_vector) 167 | assert delta < TOLERANCE 168 | 169 | 170 | if __name__ == "__main__": 171 | test_heading() 172 | 173 | test_roll_right() 174 | test_roll_left() 175 | test_roll_front() 176 | test_roll_back() 177 | 178 | test_roll() 179 | test_pitch() 180 | 181 | test_world_to_plate_to_world() 182 | test_plate_to_world_to_plate() 183 | -------------------------------------------------------------------------------- /test_moab_perf.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for Moab physics model 3 | """ 4 | __copyright__ = "Copyright 2020, Microsoft Corp." 5 | 6 | # pyright: strict 7 | 8 | import time 9 | import os 10 | 11 | from bonsai_common import Schema 12 | from microsoft_bonsai_api.simulator.client import BonsaiClientConfig 13 | from moab_model import MoabModel 14 | from moab_sim import MoabSim 15 | 16 | 17 | def run_model_for_count(count: int): 18 | """ Runs the model without actions for a duration """ 19 | # sync the plate position with commanded position 20 | model = MoabModel() 21 | model.reset() 22 | model.update_plate(True) 23 | 24 | model.plate_noise = 0 25 | model.ball_noise = 0 26 | model.jitter = 0 27 | 28 | # run for count 29 | iterations = 0 30 | while iterations < count: 31 | model.step() 32 | iterations += 1 33 | 34 | 35 | def run_sim_for_count(config: Schema, action: Schema, count: int) -> MoabSim: 36 | """ 37 | This uses a passed in config to init the sim and then runs 38 | it for a fixed duration with no actions. 39 | 40 | We do not connect to the platform and drive the loop ourselves. 41 | """ 42 | service_config = BonsaiClientConfig(workspace="moab", access_key="utah") 43 | sim = MoabSim(service_config) 44 | 45 | # run with no actions for N seconds. 46 | sim.episode_start(config) 47 | 48 | # disable noise 49 | sim.model.plate_noise = 0 50 | sim.model.ball_noise = 0 51 | sim.model.jitter = 0 52 | 53 | iterations = 0 54 | while iterations < count: 55 | sim.episode_step(action) 56 | iterations += 1 57 | 58 | # return the results 59 | return sim 60 | 61 | 62 | """ 63 | Speed tests. 64 | 65 | Run the simulation for N runs, M times each. 66 | Average FPS count and assert if it drops below threshold. 67 | 68 | This tests for: 69 | - regressions in simulator performance 70 | """ 71 | 72 | # constants for speed tests 73 | MAX_RUNS = 10 74 | MAX_ITER = 250 75 | 76 | # use FPS fail limit from env if it is available 77 | ENV_FPS_FAIL_LIMIT = os.environ.get("MOAB_PERF_TEST_THRESHOLD") 78 | FPS_FAIL_LIMIT = int(ENV_FPS_FAIL_LIMIT) if ENV_FPS_FAIL_LIMIT else 500 79 | 80 | 81 | def test_model_perf(): 82 | avg_fps = 0 83 | for _ in range(0, MAX_RUNS): 84 | 85 | start = time.time() 86 | run_model_for_count(MAX_ITER) 87 | end = time.time() 88 | 89 | fps = MAX_ITER / (end - start) 90 | avg_fps = avg_fps + fps 91 | avg_fps /= MAX_RUNS 92 | 93 | print("model average avg fps: ", avg_fps) 94 | assert ( 95 | avg_fps > FPS_FAIL_LIMIT 96 | ), "Iteration speed for Model dropped below {} fps.".format(FPS_FAIL_LIMIT) 97 | 98 | 99 | def test_sim_perf(): 100 | avg_fps = 0 101 | for _ in range(0, MAX_RUNS): 102 | start = time.time() 103 | run_sim_for_count( 104 | {}, 105 | { 106 | "input_pitch": 0.0, 107 | "input_roll": 0.0, 108 | }, 109 | MAX_ITER, 110 | ) 111 | end = time.time() 112 | 113 | fps = MAX_ITER / (end - start) 114 | avg_fps = avg_fps + fps 115 | avg_fps /= MAX_RUNS 116 | 117 | print("simulator average avg fps: ", avg_fps) 118 | assert ( 119 | avg_fps > FPS_FAIL_LIMIT 120 | ), "Iteration speed for Simulator dropped below {} fps.".format(FPS_FAIL_LIMIT) 121 | 122 | 123 | if __name__ == "__main__": 124 | test_model_perf() 125 | test_sim_perf() 126 | -------------------------------------------------------------------------------- /test_moab_sim.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for Moab simulator 3 | """ 4 | __copyright__ = "Copyright 2020, Microsoft Corp." 5 | 6 | # Temporarily set reportIncompatibleMethodOverride to false to work 7 | # around a short-term bug in the typeshed stub builtins file. 8 | # This can be removed at a later time. 9 | # pyright: strict, reportIncompatibleMethodOverride=false 10 | 11 | import math 12 | from typing import Any, Dict, Iterator, TypeVar, cast 13 | 14 | from microsoft_bonsai_api.simulator.client import BonsaiClientConfig 15 | from bonsai_common import Schema 16 | from moab_model import DEFAULT_PLATE_RADIUS 17 | from moab_sim import MoabSim 18 | 19 | _KT = TypeVar("_KT") 20 | _VT = TypeVar("_VT") 21 | 22 | 23 | def run_for_duration(config: Schema, duration: float) -> MoabSim: 24 | """ 25 | This uses a passed in config to init the sim and then runs 26 | it for a fixed duration with no actions. 27 | 28 | We do not connect to the platform and drive the loop ourselves. 29 | """ 30 | service_config = BonsaiClientConfig(workspace="moab", access_key="utah") 31 | sim = MoabSim(service_config) 32 | 33 | # run with no actions for N seconds. 34 | elapsed = 0.0 35 | sim.episode_start(config) 36 | 37 | # disable noise 38 | sim.model.plate_noise = 0 39 | sim.model.ball_noise = 0 40 | sim.model.jitter = 0 41 | 42 | while elapsed < duration: 43 | sim.episode_step({}) 44 | elapsed += sim.model.step_time 45 | 46 | # return the results 47 | return sim 48 | 49 | 50 | """ 51 | These test the speed/direction initialization at episode_start 52 | to make sure the behaviors match expectations. 53 | 54 | We use a flat plate and a fixed time so that final `d = vt` 55 | """ 56 | 57 | DURATION = 1.0 # s 58 | SPEED = DEFAULT_PLATE_RADIUS / 4 # m/s 59 | DIST = SPEED 60 | 61 | 62 | def close(value: float, target: float = 0.0, epsilon: float = 0.001) -> bool: 63 | """ returns True is value is within epsilon of target """ 64 | if (target - epsilon) <= value <= (target + epsilon): 65 | return True 66 | return False 67 | 68 | 69 | def test_zero_speed_direction(): 70 | """ test that no velocity and direction results in no motion """ 71 | config = { 72 | "target_x": 0.0, 73 | "target_y": 0.0, 74 | "initial_speed": 0.0, 75 | "initial_direction": 0.0, 76 | } 77 | 78 | sim = run_for_duration(config, DURATION) 79 | assert close(sim.model.ball.x) and close(sim.model.ball.y) 80 | 81 | 82 | def test_towards(): 83 | """ move towards [0,0] from [-DIST,0] """ 84 | config = { 85 | "time_delta": 0.010, 86 | "target_x": 0.0, 87 | "target_y": 0.0, 88 | "initial_x": -DIST, 89 | "initial_speed": SPEED, 90 | "initial_direction": 0.0, 91 | } 92 | 93 | sim = run_for_duration(config, DURATION) 94 | assert close(sim.model.ball.x) and close(sim.model.ball.y) 95 | 96 | 97 | def test_away(): 98 | """ move away from target """ 99 | config = { 100 | "time_delta": 0.010, 101 | "target_x": -(DIST + 0.0001), 102 | "initial_x": -DIST, 103 | "initial_speed": SPEED, 104 | "initial_direction": math.radians(180), 105 | } 106 | 107 | sim = run_for_duration(config, DURATION) 108 | assert close(sim.model.ball.x), "Ball X coord should be zero" 109 | assert close(sim.model.ball.y), "Ball Y coord should be zero" 110 | 111 | 112 | def test_angle(): 113 | """ move from center, 90 degrees rotated from target to the left """ 114 | config = { 115 | "time_delta": 0.010, 116 | "target_x": -(DIST), 117 | "target_y": 0, 118 | "initial_x": 0, 119 | "initial_y": 0, 120 | "initial_speed": SPEED, 121 | "initial_direction": math.radians(90), 122 | } 123 | 124 | sim = run_for_duration(config, DURATION) 125 | assert close(sim.model.ball.x), "Ball X coord should be zero" 126 | assert close(sim.model.ball.y, -DIST), "Ball Y coord should be -{}".format(DIST) 127 | 128 | 129 | def test_angle2(): 130 | config = { 131 | "target_x": 0, 132 | "target_y": DIST, 133 | "initial_x": 0, 134 | "initial_y": 0, 135 | "initial_speed": SPEED / 10.0, 136 | # just to the left 137 | "initial_direction": math.radians(90.0), 138 | } 139 | 140 | sim = run_for_duration(config, DURATION) 141 | direction = sim.model.state()["estimated_direction"] 142 | assert direction > 0.0, "Direction should be positive" 143 | 144 | # just to the right 145 | config["initial_direction"] = math.radians(-90.0) 146 | 147 | sim = run_for_duration(config, DURATION) 148 | direction = sim.model.state()["estimated_direction"] 149 | assert direction < 0.0, "Direction should be negative" 150 | 151 | 152 | class KeyProbe(Dict[_KT, _VT]): 153 | """ 154 | A "dictionary" that checks to see which keys 155 | are queried on it. 156 | """ 157 | 158 | def __init__(self): 159 | self.found_keys = set() 160 | 161 | def get(self, key: str, default: _VT) -> _VT: 162 | self.found_keys.add(key) 163 | return default 164 | 165 | 166 | def test_interface(): 167 | """ 168 | This tests that the state, config and action descriptions 169 | has matching values used by the model. 170 | """ 171 | service_config = BonsaiClientConfig(workspace="moab", access_key="utah") 172 | sim = MoabSim(service_config) 173 | model = sim.model 174 | iface = sim.get_interface() 175 | model_state = model.state() 176 | 177 | # state is pull from the model 178 | 179 | fields = cast(Dict[str, Any], iface.description)["state"]["fields"] 180 | state_fields = set(cast(Iterator[str], map(lambda x: x["name"], fields))) 181 | 182 | # interface in state 183 | for attr in state_fields: 184 | if model_state.get(attr) is None: 185 | assert ( 186 | False 187 | ), "Unexpected variable {} found in interface State but missing from model State.".format( 188 | attr 189 | ) 190 | 191 | # state in interface 192 | for attr in model_state: 193 | if attr not in state_fields: 194 | assert ( 195 | False 196 | ), "Unexpected variable {} found in model State, missing from interface State.".format( 197 | attr 198 | ) 199 | 200 | # extract config and action from interface 201 | fields = cast(Dict[str, Any], iface.description)["config"]["fields"] 202 | config_fields = set(map(lambda x: x["name"], fields)) 203 | 204 | # collect the model config 205 | config_probe = KeyProbe() # type: KeyProbe[str, Any] 206 | sim.episode_start(config_probe) 207 | model_config = config_probe.found_keys 208 | 209 | # interface in config 210 | for attr in config_fields: 211 | if attr not in model_config: 212 | assert ( 213 | False 214 | ), "Unexpected variable {} found in Config interface but missing from model Config.".format( 215 | attr 216 | ) 217 | 218 | # state in interface 219 | for attr in model_config: 220 | if attr not in config_fields: 221 | assert ( 222 | False 223 | ), "Unexpected variable {} found in model Config, missing from Config interface.".format( 224 | attr 225 | ) 226 | 227 | # Now do the actions 228 | fields = cast(Dict[str, Any], iface.description)["action"]["fields"] 229 | action_fields = set(cast(Iterator[str], map(lambda x: x["name"], fields))) 230 | 231 | # collect the model actions 232 | action_probe = KeyProbe() # type: KeyProbe[str, Any] 233 | sim.episode_step(action_probe) 234 | model_action = action_probe.found_keys 235 | 236 | # interface in action 237 | for attr in action_fields: 238 | if attr not in model_action: 239 | assert ( 240 | False 241 | ), "Unexpected variable {} found in Action interface but missing from model Action.".format( 242 | attr 243 | ) 244 | 245 | # state in action 246 | for attr in model_action: 247 | if attr not in action_fields: 248 | assert ( 249 | False 250 | ), "Unexpected variable {} found in model Action, missing from Action interface.".format( 251 | attr 252 | ) 253 | 254 | pass 255 | 256 | 257 | if __name__ == "__main__": 258 | test_interface() 259 | test_zero_speed_direction() 260 | 261 | test_towards() 262 | test_away() 263 | test_angle() 264 | test_angle2() 265 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/moabsim-py/54bb72bf47f6c23607f910d0ba352c07f0a7ba73/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Helper function for arguments for pytest, with defaults 2 | def pytest_addoption(parser): 3 | parser.addoption("--brain_name", action="store", default="my_brain") 4 | parser.addoption("--brain_version", action="store", default=1) 5 | parser.addoption("--concept_name", action="store", default="MoveToCenter") 6 | parser.addoption("--file_name", action="store", default="assess_config.json") 7 | parser.addoption("--simulator_package_name", action="store", default="Moab") 8 | parser.addoption("--instance_count", action="store", default="20") 9 | parser.addoption("--custom_assess_name", action="store", default="my_custom_assessment") 10 | parser.addoption("--log_analy_workspace", action="store", default=None) 11 | parser.addoption("--inkling_fname", action="store", default="./Machine-Teaching-Examples/model_import/moab-imported-concept.ink") 12 | parser.addoption("--import_name", action="store", default="My ML Model") 13 | parser.addoption("--model_file_path", action="store", default="./Machine-Teaching-Examples/model_import/state_transform_deep.zip") 14 | parser.addoption("--episode_iteration_limit", action="store", default=250) -------------------------------------------------------------------------------- /tests/test_model_import.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pytest for confirming model import assessment works using Telescope data 3 | retrieval from LogAnalyticsDataClient. Usage: 4 | 5 | Train a brain using model import's instructions, then run 6 | 7 | pytest tests/test_model_import.py -s \ 8 | --brain_name \ 9 | --log_analy_workspace \ 10 | --custom_assess_name \ 11 | --model_file_path "./Machine-Teaching-Examples/model_import/state_transform_deep.zip" 12 | 13 | or 14 | --model_file_path "./Machine-Teaching-Examples/model_import/state_transform_deep.onnx" 15 | """ 16 | 17 | __author__ = "Journey McDowell" 18 | __copyright__ = "Copyright 2021, Microsoft Corp." 19 | 20 | import os 21 | import pytest 22 | import glob 23 | import pandas as pd 24 | import numpy as np 25 | from azure.loganalytics import LogAnalyticsDataClient 26 | from azure.common.credentials import get_azure_cli_credentials 27 | from azure.loganalytics.models import QueryBody 28 | import ast 29 | import json 30 | import matplotlib.pyplot as plt 31 | import time 32 | 33 | # Allowing optional flags to replace defaults for pytest from tests/conftest.py 34 | @pytest.fixture() 35 | def brain_name(pytestconfig): 36 | return pytestconfig.getoption("brain_name") 37 | 38 | @pytest.fixture() 39 | def brain_version(pytestconfig): 40 | return pytestconfig.getoption("brain_version") 41 | 42 | @pytest.fixture() 43 | def concept_name(pytestconfig): 44 | return pytestconfig.getoption("concept_name") 45 | 46 | # json file with assessment configs for input 47 | @pytest.fixture() 48 | def file_name(pytestconfig): 49 | return pytestconfig.getoption("file_name") 50 | 51 | @pytest.fixture() 52 | def simulator_package_name(pytestconfig): 53 | return pytestconfig.getoption("simulator_package_name") 54 | 55 | @pytest.fixture() 56 | def inkling_fname(pytestconfig): 57 | return pytestconfig.getoption("inkling_fname") 58 | 59 | @pytest.fixture() 60 | def instance_count(pytestconfig): 61 | return pytestconfig.getoption("instance_count") 62 | 63 | @pytest.fixture() 64 | def custom_assess_name(pytestconfig): 65 | return pytestconfig.getoption("custom_assess_name") 66 | 67 | @pytest.fixture() 68 | def log_analy_workspace(pytestconfig): 69 | return pytestconfig.getoption("log_analy_workspace") 70 | 71 | @pytest.fixture() 72 | def import_name(pytestconfig): 73 | return pytestconfig.getoption("import_name") 74 | 75 | @pytest.fixture() 76 | def model_file_path(pytestconfig): 77 | return pytestconfig.getoption("model_file_path") 78 | 79 | @pytest.fixture() 80 | def episode_iteration_limit(pytestconfig): 81 | return pytestconfig.getoption("episode_iteration_limit") 82 | 83 | # Use CLI to import a ML model as .onnx or tf 84 | def test_model_import(import_name, model_file_path): 85 | os.system('bonsai importedmodel create --name "{}" --modelfilepath {}'.format( 86 | import_name, 87 | model_file_path, 88 | )) 89 | 90 | os.system('bonsai importedmodel show --name "{}" -o json > status.json'.format( 91 | import_name, 92 | )) 93 | 94 | # Confirm model import succeeded 95 | with open('status.json') as fname: 96 | status = json.load(fname) 97 | 98 | assert status['Status'] == 'Succeeded' 99 | 100 | # Use CLI to create, upload inkling, train, and wait til complete 101 | def test_train_brain(brain_name, brain_version, inkling_fname, simulator_package_name): 102 | os.system('bonsai brain create -n {}'.format( 103 | brain_name, 104 | )) 105 | os.system('bonsai brain version update-inkling -n {} --version {} -f {}'.format( 106 | brain_name, 107 | brain_version, 108 | inkling_fname 109 | )) 110 | concept_names = ['ImportedConcept', 'MoveToCenter'] 111 | for concept in concept_names: 112 | time.sleep(20) 113 | if concept == 'ImportedConcept': 114 | os.system('bonsai brain version start-training -n {} --version {} -c {}'.format( 115 | brain_name, 116 | brain_version, 117 | concept 118 | )) 119 | else: 120 | os.system('bonsai brain version start-training -n {} --version {} --simulator-package-name {} -c {}'.format( 121 | brain_name, 122 | brain_version, 123 | simulator_package_name, 124 | concept 125 | )) 126 | 127 | # Do not continue until training is complete 128 | running = True 129 | while running: 130 | time.sleep(20) 131 | os.system('bonsai brain version show --name {} --version {} -o json > status.json'.format( 132 | brain_name, 133 | brain_version, 134 | )) 135 | with open('status.json') as fname: 136 | status = json.load(fname) 137 | if status['trainingState'] == 'Active': 138 | pass 139 | else: 140 | running = False 141 | time.sleep(300) 142 | print('Training complete...') 143 | 144 | # Final check concepts are complete 145 | with open('status.json') as fname: 146 | status = json.load(fname) 147 | assert status['status'] == 'Succeeded' 148 | print('All Concepts trained') 149 | 150 | # Main test function for 151 | # 1. running custom assessment using bonsai-cli 152 | # 2. retrieving data from Log Analytics Workspace using LogAnalyticsDataClient 153 | # 3. flattening states, actions, and configs 154 | # 4. making plots for episode metrics 155 | # 5. qualifying pass/fail 156 | def test_assessment_brain(brain_name, brain_version, concept_name, file_name, simulator_package_name, instance_count, custom_assess_name, log_analy_workspace, episode_iteration_limit): 157 | # Run custom assessment 158 | os.system('bonsai brain version assessment start --brain-name {} --brain-version {} --concept-name {} --file {} --simulator-package-name {} --instance-count {} --name {} --episode-iteration-limit {}'.format( 159 | brain_name, 160 | brain_version, 161 | concept_name, 162 | file_name, 163 | simulator_package_name, 164 | instance_count, 165 | custom_assess_name, 166 | episode_iteration_limit, 167 | )) 168 | 169 | # Do not continue until assessment is complete and waited 5 minutes 170 | running = True 171 | while running: 172 | time.sleep(10) 173 | os.system('bonsai brain version assessment show --brain-name {} --brain-version {} --name {} -o json > status.json'.format( 174 | brain_name, 175 | brain_version, 176 | custom_assess_name 177 | )) 178 | with open('status.json') as fname: 179 | status = json.load(fname) 180 | if status['status'] == 'Completed': 181 | running = False 182 | print('Assessment complete, waiting 5 min for data to appear in LAW...') 183 | for i in range(5): 184 | print('{} min...'.format(5-i)) 185 | time.sleep(60) 186 | 187 | # Extract telescope from LAW using workspace ID and return flattened 188 | df = extract_telescope(log_analy_workspace, brain_name, brain_version, custom_assess_name) 189 | 190 | df = df.reset_index(drop=True) 191 | 192 | # Make plots 193 | # EpisodeIndex based on unique EpisodeIds 194 | df['EpisodeIndex'] = np.zeros(len(df)) 195 | k = 1 196 | for i in list(set(df['EpisodeId'])): 197 | for j in range(len(df)): 198 | if df['EpisodeId'].iloc[j] == i: 199 | df.at[j,'EpisodeIndex'] = k 200 | k += 1 201 | 202 | # Manipulate 203 | df['distance_to_center'] = np.sqrt(df['ball_x'] ** 2 + df['ball_y'] ** 2) 204 | df['velocity_magnitude'] = np.sqrt(df['ball_vel_x'] ** 2 + df['ball_vel_y'] ** 2) 205 | 206 | # Create dataframe consisting of episode finish metrics 207 | df_last = pd.DataFrame({}) 208 | mse_dist_list = [] 209 | mse_vel_list = [] 210 | for ep in range(1, int(df['EpisodeIndex'].max())+1): 211 | last_iter = df[(df['EpisodeIndex']==ep) & (df['IterationIndex']==len(df[df['EpisodeIndex']==ep]))] 212 | print(last_iter.head()) 213 | 214 | mse_dist_list.append(np.square(np.subtract(0, df[(df['EpisodeIndex']==ep)]['distance_to_center'])).mean()) 215 | mse_vel_list.append(np.square(np.subtract(0, df[(df['EpisodeIndex']==ep)]['velocity_magnitude'])).mean()) 216 | 217 | df_last = pd.concat([df_last, last_iter], sort=False) 218 | 219 | df_last['mse_dist'] = mse_dist_list 220 | df_last['mse_vel'] = mse_vel_list 221 | 222 | # Create dataframe consisting of summary info 223 | df_summary = {} 224 | df_summary['percentage_full_episodes'] = (len(df[df['IterationIndex']==251]) / df['EpisodeIndex'].max()) * 100 225 | df_summary['avg_final_distance_to_center'] = np.mean(df_last['distance_to_center']) 226 | df_summary['avg_final_velocity_magnitude'] = np.mean(df_last['velocity_magnitude']) 227 | df_summary['mse_dist_total'] = np.square(np.subtract(0, df['distance_to_center'])).mean() 228 | df_summary['mse_vel_total'] = np.square(np.subtract(0, df['velocity_magnitude'])).mean() 229 | 230 | # Save Summary values as json 231 | with open('brain_summary.json', 'w') as outfile: 232 | json.dump(df_summary, outfile) 233 | 234 | # Plot Final Values 235 | fig, ax = plt.subplots(1, 1, figsize=(16, 8)) 236 | episodes = [i for i in range(1, int(df['EpisodeIndex'].max()+1))] 237 | ax.plot(episodes, df_last['distance_to_center']) 238 | ax.plot(episodes, df_last['velocity_magnitude']) 239 | ax.legend(['Final Distance to Center', 'Final Velocity Mag']) 240 | ax.set_xlabel('Custom Assessment Episodes') 241 | fig.suptitle('brain \n Percentage of Full Episodes: {:0.2f}% \n Average Final Ball Distance To Center: {:0.4f} \n Average Final Ball Velocity Mag: {:0.4f} \n'.format(df_summary['percentage_full_episodes'], df_summary['avg_final_distance_to_center'], df_summary['avg_final_velocity_magnitude']), fontsize=14) 242 | 243 | # Plot MSE Values 244 | figa, axa = plt.subplots(1, 1, figsize=(16, 8)) 245 | axa.plot(episodes, df_last['mse_dist']) 246 | axa.plot(episodes, df_last['mse_vel']) 247 | axa.legend(['MSE Distance', 'MSE Vel Mag']) 248 | axa.set_xlabel('Custom Assessment Episodes') 249 | figa.suptitle('brain \n Percentage of Full Episodes: {:0.2f}% \n Average MSE Ball Distance To Center: {:0.4f} \n Average MSE Ball Velocity Mag: {:0.4f} \n'.format(df_summary['percentage_full_episodes'], df_summary['mse_dist_total'], df_summary['mse_vel_total']), fontsize=14) 250 | 251 | 252 | print('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~') 253 | for key, val in df_summary.items(): 254 | print(key, val) 255 | print('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~') 256 | #plt.show() 257 | 258 | # Assert tests for qualification 259 | assert df_summary['percentage_full_episodes'] >= 50 260 | assert df_summary['avg_final_distance_to_center'] <= 0.1 261 | assert df_summary['avg_final_velocity_magnitude'] <= 0.1 262 | assert df_summary['mse_dist_total'] <= 0.008 263 | assert df_summary['mse_vel_total'] <= 0.008 264 | 265 | # Extract telescope data using query 266 | @pytest.mark.skip(reason="helper") 267 | def extract_telescope(log_analy_workspace_id, brain_name, brain_version, assessment_name): 268 | creds, _ = get_azure_cli_credentials(resource="https://api.loganalytics.io") 269 | log_client = LogAnalyticsDataClient(creds) 270 | myWorkSpaceId = log_analy_workspace_id 271 | result = log_client.query(myWorkSpaceId, QueryBody(**{ 272 | 'query': ( 273 | 'EpisodeLog_CL' 274 | '| where BrainName_s == "{}" and BrainVersion_d == "{}" and AssessmentName_s == "{}"' 275 | '| where TimeGenerated > ago(30d)' 276 | '| join kind=inner (' 277 | 'IterationLog_CL' 278 | '| sort by Timestamp_t desc' 279 | ') on EpisodeId_g' 280 | '| project AssessmentName = AssessmentName_s, EpisodeId = EpisodeId_g, IterationIndex = IterationIndex_d, Timestamp = Timestamp_t, SimState = parse_json(SimState_s), SimAction = parse_json(SimAction_s), Reward = Reward_d, CumulativeReward = CumulativeReward_d, Terminal = Terminal_b, LessonIndex = LessonIndex_d, SimConfig = parse_json(SimConfig_s), GoalMetrics = parse_json(GoalMetrics_s), EpisodeType = EpisodeType_s, FinishReason = FinishReason_s' 281 | '| order by EpisodeId asc, IterationIndex asc' 282 | ).format(brain_name, str(brain_version), assessment_name) 283 | })) 284 | 285 | df = pd.DataFrame(result.tables[0].rows, columns=[result.tables[0].columns[i].name for i in range(len(result.tables[0].columns))]) 286 | 287 | df_flattened = format_kql_logs(df) 288 | df_flattened.to_csv('flattened_telescope.csv') 289 | return df_flattened 290 | 291 | # Flatten data 292 | @pytest.mark.skip(reason="helper") 293 | def format_kql_logs(df: pd.DataFrame) -> pd.DataFrame: 294 | ''' Function to format a dataframe obtained from KQL query. 295 | Output format: keeps only selected columns, and flatten nested columns [SimAction, SimState, SimConfig] 296 | Parameters 297 | ---------- 298 | df : DataFrame 299 | dataframe obtained from running KQL query then exporting `_kql_raw_result_.to_dataframe()` 300 | ''' 301 | selected_columns = ["Timestamp", "IterationIndex", "Reward", "CumulativeReward", "Terminal", "SimState", "SimAction", "SimConfig", "EpisodeId"] 302 | nested_columns = ["SimState", "SimAction", "SimConfig"] 303 | df_selected_columns = df[selected_columns] 304 | series_lst = [] 305 | ordered_columns = ["EpisodeId", "IterationIndex", "Reward", "Terminal"] 306 | for i in nested_columns: 307 | try: 308 | new_series = df_selected_columns[i].apply(ast.literal_eval).apply(pd.Series) 309 | except: 310 | df_selected_columns[i].fillna(value=str({key: str(np.nan) for key, value in ast.literal_eval(df_selected_columns[i][1]).items()}), inplace=True) 311 | new_series = df_selected_columns[i].apply(ast.literal_eval).apply(pd.Series) 312 | column_names = new_series.columns.values.tolist() 313 | series_lst.append(new_series) 314 | if len(column_names) > 0: 315 | ordered_columns.extend(column_names) 316 | del(df_selected_columns[i]) 317 | 318 | series_lst.append(df_selected_columns) 319 | formated_df = pd.concat(series_lst, axis=1) 320 | formated_df = formated_df.sort_values(by='Timestamp',ascending=True) # reorder df based on Timestamp 321 | formated_df.index = range(len(formated_df)) # re-index 322 | formated_df['Timestamp']=pd.to_datetime(formated_df['Timestamp']) # convert Timestamp to datetime 323 | 324 | formated_df = formated_df[ordered_columns] 325 | return formated_df.sort_values(by=["EpisodeId", "IterationIndex"]) --------------------------------------------------------------------------------