Open DocGarbanzo opened 2 years ago
I really like this direction. I really like that the user could spin-up a vehicle without needing any Python or even a configuration file. There a few things to think about.
The yaml approach, without some other support, will require the user to read a lot of python to figure out which parts are available and what are their constructor arguments, inputs and outputs. The user, who we want to insulate from the needing to know the internals of the Python code, must read each part's python code to know what are the constructor arguments, the run() methods arguments (the inputs) and the run method's return values (the outputs). So in this model we are actually requiring the user to read the python and pull out this information before they can create their yaml file. In some ways that is worse than the current situation, which for the most part only requires uncommenting and editing the myconfig.py file. However, with more documentation and/or tooling we can overcome that issue.
The myconfig.py file is currently a de-facto yellow pages for parts and their configuration. If a part doesn't have an entry in myconfig.py it may never get used. If we move this new approach then we won't rely on a monolithic configuration file (and that is good), but how does a user know what parts are available and how to configure them?
How do we handle more than one instance of a part?
I don't want any of the questions I have raised to diminish how important this proposal is. Donkeycar is targeted at relatively sophisticated beginners and even they often get stuck. This approach could open up autonomous racing/robotics to a lot more people and I would think with much less pain.
The current monolithic approach is geared towards beginners and those who are not python coders. The approach should initially be geared towards those who are able to take a deeper dive into the code to build there own parts.
I have prototyped a "template" that uses this method.
I am using the code below today and have found it great to debugand change part configuration. Seeing the inputs and outputs all in one place is great as I change the parts in use.
parts:
donkeycar.parts.controller:
enable: True
class: LocalWebController
args:
port: cfg.WEB_CONTROL_PORT
mode: cfg.WEB_INIT_MODE
inputs: [cam/image_array, tub/num_records]
outputs: [user/angle, user/throttle, user/mode, recording]
thread: True
donkeycar.parts.fx.helpers:
enable: True
class: PilotCondition
args: {}
inputs: [user/mode]
outputs: [run_pilot]
donkeycar.parts.fx.helpers:
enable: True
class: AI_Pilot
args:
cfg: cfg
inputs: [cam/image_array]
outputs: [pilot/angle, pilot/throttle]
run_condition: run_pilot
donkeycar.parts.launch:
enable: True
class: AiLaunch
args:
launch_duration: cfg.AI_LAUNCH_DURATION
launch_throttle: cfg.AI_LAUNCH_THROTTLE
keep_enabled: cfg.AI_LAUNCH_KEEP_ENABLED
inputs: [user/mode, pilot/throttle]
outputs: [pilot/throttle]
donkeycar.parts.fx.helpers:
enable: True
class: DriveMode
args:
cfg: cfg
inputs: [user/mode, user/angle, user/throttle, pilot/angle, pilot/throttle]
outputs: [angle, throttle]
donkeycar.parts.dgym:
enable: True
class: DonkeyGymEnv
args:
sim_path: cfg.DONKEY_SIM_PATH
host: cfg.SIM_HOST
env_name: cfg.DONKEY_GYM_ENV_NAME
conf: cfg.GYM_CONF
delay: cfg.SIM_ARTIFICIAL_LATENCY
inputs: [angle, throttle, brake]
outputs: [cam/image_array]
thread: True
donkeycar.parts.tub_v2:
enable: True
class: TubWriter
args:
base_path: cfg.DATA_PATH
inputs: [cam/image_array, user/angle, user/throttle, user/mode]
types: [image_array, float, float, str]
metadata: []
inputs: [cam/image_array,user/angle, user/throttle, user/mode]
outputs: [tub/num_records]
run_condition: recording
import logging
import import lib
import yaml
from docent import docent
import donkeycar as dk
#__________________________________ ASSEMBLE THE VEHICLE________________________
def assemble(cfg):
#Initialize car
V = dk.vehicle.Vehicle()
# Load the YAML file specifying the parts
with open(cfg.PARTS_PATH) as file:
try:
dict = yaml.safe_load(file)
except yaml.YAMLError as exception:
logger.error(exception)
parts = dict.get("parts")
if parts is None:
logger.error("parts.yml is missing the parts key")
raise Exception()
for mod_name, class_name in parts.items():
#import module <mod_name>
module = importlib.import_module(mod_name)
logger.info(f'{module.__name__} imported.')
#create part object from <class_name>
part_class = getattr(module, class_name)
part = part_class(cfg)
logger.info(f' {class_name} part created')
#add part to vehicle
V.add(part, inputs=part.inputs, outputs=part.outputs, threaded=part.threaded, run_condition=part.run_condition)
logger.info(f' {class_name} part added to vehicle')
logger.info(f' Inputs {part.inputs}')
logger.info(f' Outputs {part.outputs}')
return V
#__________________________________ MAIN _________________________________________
if __name__ == '__main__':
# load and check command line options
args = docopt(__doc__)
# assemble vehicle with configuration parameters
configuration = dk.load_config(myconfig=args['--myconfig'])
logger.info('\nAssembling vehicle from parts...')
vehicle = assemble(configuration)
# start the vehicle
logger.info('Start your engines...')
vehicle.start(rate_hz=configuration.DRIVE_LOOP_HZ, max_loop_count=configuration.MAX_LOOPS)
logger.info('Vehicle end.\n')
`
Hi @Ezward and @cfox570, Thanks for that very detailed feedback, you have really understood the approach and possible problems / shortcomings and non-addressed questions. Let me address all of these items here:
1) Perhaps we can do some introspection of a part to figure out what the arguments, inputs and outputs are. All of that would be much easier if we added type annotations to the parts.
_The arguments are recorded in the factory that registers all parts. They can and will be used in order to facilitate an easy construction, such that the user doesn't have to know them. Type annotations are good, but here we probably need doc strings to explain what they are meant to be. I'm figuring out how to get the docstring arguments passed through. Inputs/outputs are really just string labels to pass the data through the car. We have chosen to use some standard names like cam/image_array
etc. I was not thinking of restricting them, but we could give it a thought._
2) Perhaps we simply maintain a directory of parts. This could be very simple to implement. It could be hierarchical; so we can group parts by their uses, independent of what python file they are implemented in. The directory would define the arguments, inputs and outputs and provide a lot more description that we could get simply by introspection. The parts are all registered in the factory, and through the module structure they form some hierarchy. It would be easier to keep these in sync than adding another hierarchy independently of that. I'd rather suggest to move some parts between files or directories, to create a clear structure. I think the code should be as hierarchical as the parts themselves (cameras, sensors, actuators, etc,)
3) Perhaps we define a comment block format for a part that can then be scraped to provide this information. That has the advantage that it would keep the documentation with the part. Any author of a new part could simply add this comment part to have their part incorporated into the directory. We could make this part of a part's code review. Yes
4) If we know the constructor arguments and inputs and outputs for a given part, we could create a editor that would allow the user to pick a part and drop it in the vehicle. I think about how donkey ui could support the entire work flow for donkey car, include vehicle definition and configuration. Super cool to think about. I obviously thought about this. It could be donkey UI using drop down menus, but I think we can easily reverse engineer a dot graph, so you could draw your vehicle in a graphical app, like dagitty or so (there are also interactive dot builders). We build the yaml from the dot file and then create the car from the yaml. It's possible but requires a bit of research. The donkey UI with drop downs is more straight-forward, I can do this.
5) How do we handle more than one instance of a part? You can just repeat any part as often as you want. Each entry creates an own instance (it's a list not a dict)
parts:
- mockcamera:
outputs: [cam/image_array_l]
threaded: true
- mockcamera:
outputs: [cam/image_array_r]
threaded: true
@cfox570 - your stuff looks fantastic and clear. It's straight forward and very similar to what I have done. There is one line in your code which doesn't make sense to me (maybe I'm not seeing it):
part = part_class(cfg)
You seem to be passing the config to the part constructor, but most parts have dedicated arguments. However, in my factory based version I do propose to implement a create
function optionally that takes the cfg
as an argument. This will hide all kind of intricate lookup and iterative object constructions from the user who simply want's to create the part. Anyway, what you have done is basically the exact same idea in a bit more basic form. And what you are implying is, that with such an approach we don't need a car template python file any longer, as your code above can just be part of the donkey library. Instead a car config is a just a yaml.
Let me know if you want to join me on bringing this into Donkey Car, then I will push my branch into autorope and we can work on that together.
I apologize I pasted an older version. In this version,
part = part_class(**theargs)
Here is the complete code I am currently using below.
I recommend you think about a two stage process.
#!/usr/bin/env python3
"""
Assemble and drive a donkeycar.
Parts are specified in parts.yml in the order they need to execute.
Usage:
ymldriver.py [--myconfig=<filename>] [--model=<model>] [--type=(linear|categorical|tflite_linear|tensorrt_linear)]
Options:
-h --help Show this screen.
--myconfig=filename Specify myconfig file to use. [default: myconfig.py]
--model=model Path to model. The default value is MODEL_PATH in the config file
--type=type Type of model. The default value is DEFAULT_MODEL_TYPE in the config file
"""
import logging
import importlib
from docopt import docopt
import donkeycar as dk
import yaml
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
#__________________________________ ASSEMBLE THE VEHICLE________________________
def assemble(cfg):
#Initialize car
V = dk.vehicle.Vehicle()
# Load the YAML file specifying the parts
with open(cfg.PARTS_PATH) as file:
try:
dict = yaml.safe_load(file)
except yaml.YAMLError as exception:
logger.error(exception)
return None
parts = dict.get("parts")
if parts is None:
logger.error("parts.yml is missing the parts key")
raise Exception()
for mod_name, definition in parts.items():
# decode definition
theclass = None
theargs = None
theinputs = None
theoutputs = None
threaded = None
theruncondition = None
enable = None
for prop, value in definition.items():
# print(prop,value)
if prop == 'class':
theclass = value
elif prop == 'args':
for key, dat in value.items():
if dat[0:3] == 'cfg':
# print (f'{dat}: {eval(dat)}')
value[key] = eval(dat)
theargs = value
elif prop == 'inputs':
theinputs = value
elif prop == 'outputs':
theoutputs = value
elif prop == 'thread':
threaded = value
elif prop == 'run_condition':
theruncondition = value
elif prop == 'enable':
enable = value
if not enable:
break # stop processing the entry
else:
logger.error(f'key "{prop}" is unknown')
raise Exception()
if enable is None:
logger.error(f'Definition: enable is missing ')
raise Exception()
if not enable:
continue
if theclass is None:
logger.error(f'Definition: the class is missing ')
raise Exception()
if theargs is None:
logger.error(f'Definition: the args are missing ')
raise Exception()
if theinputs is None:
logger.error(f'Definition: the inputs are missing ')
raise Exception()
if theoutputs is None:
logger.error(f'Definition: the outputs are missing ')
raise Exception()
if threaded is None:
threaded = False
# import module <mod_name>
module = importlib.import_module(mod_name)
logger.info(f'{module.__name__} imported.')
#create part object from <class_name>
part_class = getattr(module, theclass)
part = part_class(**theargs)
logger.info(f' {theclass} part created') # from {type(part.super())
# add part to vehicle
V.add(part, inputs=theinputs, outputs=theoutputs, threaded=threaded, run_condition=theruncondition)
logger.info(f' ... added to vehicle')
if theclass == 'MyJoystickController':
part.set_deadzone(cfg.JOYSTICK_DEADZONE)
part.print_controls()
return V
#__________________________________ MAIN _________________________________________
if __name__ == '__main__':
# load configuration and check command line options
args = docopt(__doc__)
configuration = dk.load_config(myconfig=args['--myconfig'])
if args['--type']:
configuration.DEFAULT_MODEL_TYPE = args['--type']
if args['--model']:
configuration.MODEL_PATH = args['--model']
# assemble vehicle with configuration parameters
logger.info('\nAssembling vehicle from parts...')
vehicle = assemble(configuration)
if vehicle is not None:
# start the vehicle
logger.info('Start your engines...')
vehicle.start(rate_hz=configuration.DRIVE_LOOP_HZ, max_loop_count=configuration.MAX_LOOPS)
logger.info('Vehicle end.\n')
I would definitely like to help out in developing the code. Let know when you push the branch.
@DocGarbanzo I suggest we change this issue to Allow declarative construction of vehicle
Replace the car config with a modular user-friendly design
Issue description
Currently we ship a couple of templates with Donkey Car but for the majority of applications the
complete.py
is used. It contains all possible cameras, drive trains with motor controllers, input controllers, etc, etc. In reality though a user has a car with only one camera type, one drive train, and will likely use only one controller to drive it. For the donkey gym environment we also don't need to support all the different hardware parts as the code runs on the host computer. We have a separatesimulator.py
template, but I believe this is not used. And there seems to be quite an overlap in duplicated code.Proposal
CvCam
instead of thePiCamera
.Approach
Car template
Yaml is easy to read and manipulate. Here is a car template for a simple car that uses a mock camera, the web controller and writes data to the tub.
The car builder
The car builder reads the yaml file and creates the car. In addition it also creates a graphical representation of the car as a dot file and renders it. Here is what the above car looks like:
Here, the variables that are flowing from outputs to inputs are given through solid lines, and variable that connect from outputs to run conditions are represented by dotted lines. Variables that are output, but don't go anywhere are arriving in the 'Unused' part. Variables that are inputs only are currently missing in the graph. They would create a run time error, I'll add them to the graph to prevent building ill-defined car apps.
Features
In general we prefer to have a simple template with few
if/else
options, but sometimes you might want to switch on a part or not so we support a fieldadd_only_if
. In order to connect the template with the configuration parameters we support the syntaxcfg.ABC
in order to access these parameters in the configuration. Lets make theWebFpv
from above configurable:Debugging the data flow in the car is easy, let's take the template above and put a typo into the output variable of the web controller:
user/throttle
->usr/throttle
and rebuild the car:This is the corresponding visualisation: We see, the output
usr/throttle
goes into theUnused
part. So even before running the car and observing that the recodings will have no throttle values we can see that the template will not work as expected.The code
The code requires the donkey parts to be derived from a common interface that register the constructor (by default) or a
create
class function upon class compilation. Thecreate()
function reads the config and allow for overwrites, but for simple parts it is not required, because the yaml template allow to pass constructor parameters optionally. In the above example theWebFpv
was added through acreate
method, but we could alternative add it like:It leads to a cleaner design though to hide the initialisation parameters from the user if these are stored as config parameters. But because the yaml file is a configuration file itself, the users can edit and store for their cars, we could set any such parameters in car config directly, like:
This would make a lot of configuration parameters obsolete.
Supporting own user parts
We add a dynamic loading of a
myparts.py
file in themycar
directory. This will allow users to write an own Donkey Car part w/o messing with any of the project code, which usually requires having your own GH account, forking the project, etc, etc. I will add a simple example of the required code to drive a single LED from the PCA9685 pwm driver board, which for example, would switch on, once you are in recording mode. The following steps would be required to get this part added: 1) Adding the new part to the yaml file, like:And in the
myparts.py
:Because the new part is derived from
Createable
(thecreate()
method is optional) and the yaml entry is well defined, the part would be recognised by the vehicle and it will print in the graphical representation.