From 99ee11bf214f65a009664e5f6d745906f0003414 Mon Sep 17 00:00:00 2001
From: abaucher <achille.baucher@inria.fr>
Date: Mon, 14 Mar 2022 18:04:09 +0100
Subject: [PATCH] Added comments to parse dynamo and system, added error when
 time misssing

---
 pydynamo/core/parse_dynamo_functions.py |  44 ++++-
 pydynamo/core/parse_system.py           | 251 ++++++++++++++++++------
 pydynamo/world3/plot_utils.py           |   4 +-
 3 files changed, 236 insertions(+), 63 deletions(-)

diff --git a/pydynamo/core/parse_dynamo_functions.py b/pydynamo/core/parse_dynamo_functions.py
index 3c29beff..2d6ac92a 100644
--- a/pydynamo/core/parse_dynamo_functions.py
+++ b/pydynamo/core/parse_dynamo_functions.py
@@ -1,14 +1,33 @@
+"""Parse DYNAMO special functions.
+"""
+
 import ast
 from astunparse import unparse
 from .parse_equations import reformat_eq
 
-
+# List of special functions to handle
 instance_fun_names = {'smooth', 'sample', 'dlinf3', 'delay3', 'tabhl', 'step'}
 
    
 def change_and_get_params(node, node_name):
-    # Node_name is the name of the updated node 
+    """Get information needed to call the special function in the `node`, and also change the `node` to include appropriate parameter names.
+
+    Parameters
+    ----------
+    node : ast.Module
+        Function call to handle.
+    node_name : str
+        Name of the node (variable or constant) which uses this function to be upated. This name is useful to determine the new name of the function (es: `tabhl_io` for the funciton `tabhl` and the updated variable `io`.
+    
+    Returns
+    -------
+    dict
+        All useful information to generate the special function. Depends on the type of the function.
+    """
     name = node.func.id
+
+    # For every type of special function, different treatments apply.
+    
     if name == 'tabhl':
         params = {'table': node.args[0].id,
                 'val': unparse(node.args[1]),
@@ -18,7 +37,7 @@ def change_and_get_params(node, node_name):
         new_fun_name = f"tabhl_{node_name}"
         params['fun'] = new_fun_name
         node.args = [node.args[1]]
-        # node.func.id = new_fun_name
+
 
     elif name in  {'smooth', 'dlinf3', 'delay3'}:
         params = {'val': node.args[0].value.id,
@@ -26,7 +45,7 @@ def change_and_get_params(node, node_name):
         new_fun_name = f"{name}_{node_name}"
         params['fun'] = new_fun_name
         node.args = [node.args[0], node.args[1], ast.Name('k')]
-        # node.func.id = new_fun_name
+
         
     elif name == 'sample':
         params = {'fun': f'sample_{node_name}',
@@ -42,7 +61,24 @@ def change_and_get_params(node, node_name):
     return params
     
 def get_dynamo_fun_params(root, node_name):
+    """Get information needed to execute the equation `node`, and also change `node` with appropriate parameters.
+
+    Parameters
+    ----------
+    node : ast.Module
+        Equation.
+    node_name : str
+        Name of the node (variable or constant) which uses this function to be upated. This name is useful to determine the new name of the function (es: `tabhl_io` for the funciton `tabhl` and the updated variable `io`.
+    
+    Returns
+    -------
+    (ast.Module, dict)
+        The modified node and all useful information to generate the equation.
+    """
     root = root.body[0].value
+
+    # The node is walked through to detect and change a special function call
+    # Only one special DYNAMO function is allowed in an equation ...
     for node in ast.walk(root):
         if isinstance(node, ast.Call):
             if node.func.id in instance_fun_names:
diff --git a/pydynamo/core/parse_system.py b/pydynamo/core/parse_system.py
index f5d72b8c..63af1a83 100644
--- a/pydynamo/core/parse_system.py
+++ b/pydynamo/core/parse_system.py
@@ -1,3 +1,5 @@
+"""Functions to parse an entire pydynamo code and generate a System object. Also defines every political changes.
+"""
 import ast
 import re
 import inspect
@@ -8,21 +10,41 @@ from .specials import step, clip
 from .system import System
 
 def get_comment(line):
+    """Retrieve comment by removing '#' and additional spaces.
+    """
     if '#' in line:
         return line.split('#', 1)[1].strip()
     return ''
 
 def get_nodes_eqs_dicts(lines):
+    """
 
+    Parameters
+    ----------
+    lines : iterable(str)
+        List of every equations.
+    
+    Returns
+    -------
+
+    (dict, dict, dict)
+        Nodes (variable, constants and functions), equations (constant, update and initialisation) and comments parsed in the equations list.
+    """
+    
     all_eqs = {name: dict() for name in ['cst', 'update', 'init']}
     all_nodes = {name: set() for name in ['cst', 'var', 'fun']}
     comments = {}
+
+    # For each equation, try to detect its type and retrive informations.
     for l in lines:
         root = ast.parse(l)
-        type_found = False
-        for eq_type in all_eqs:
+        type_is_identified = False
+        
+        for eq_type in all_eqs.keys():
             if is_eq_of_type(root, eq_type):
-                type_found = True
+                type_is_identified = True
+
+                # Get assigned node and equation arguments
                 try:
                     node, args = get_pars_eq(root, eq_type)
                     args['raw_line'] = l.split('#')[0].strip()
@@ -56,46 +78,110 @@ def get_nodes_eqs_dicts(lines):
                         all_nodes['fun'].add(arg_node)
 
                             
-    
-            
-        if root.body and not type_found:
+        if root.body and not type_is_identified:
             raise(SyntaxError(f"Invalid equation:\n {l}"))
 
 
     return all_nodes, all_eqs, comments
 
 def system_from_nodes_eqs(nodes, eqs, comments, s=None, prepare=True):
+    """Get a system from nodes, equations and comments dictionnaries. If a system is given, just add this equations, nodes and comments to it.
+
+    Parameters
+    ----------
+    nodes : dict
+        Node (variables, constants or functions) names.
+    eqs : dict
+        Equations (constant, initialisation or updates).
+    comments : dict(str: str)
+        Comments of each node.
+    s : System
+        System to which we add changes.
+    prepare : bool
+        If True, set all constants, and functions (constants, update and init), but not special functions.
+    """
+    # In case a System is given, eventually change variables to constants and vice-versa
     if s:
-        for n,  args in eqs['cst'].items():
+        for n, args in eqs['cst'].items():
             if n in s.eqs['update']:
                 change_var_to_cst(s, n)
-        for n,  args in eqs['update'].items():
+        for n, args in eqs['update'].items():
             if n in s.eqs['cst']:
                 change_cst_to_var(s, n)
     if not s:
         s = System()
+    
     s.add_all_nodes(nodes)
     s.add_all_eqs(eqs)
     s.add_comments(comments)
+
     if prepare:
         s.prepare()
+
     return s
 
 def system_from_lines(lines, s=None, prepare=True):
+    """Get a system from a list of pydynamo equations. If a system is given, just add this equations to it.
+
+    Parameters
+    ----------
+    lines : iterable(str)
+        List of every pydynamo equations.
+    s : System
+        System to which we add changes.
+    prepare : bool
+        If True, set all constants, and functions (constants, update and init), but not special functions.
+    """
     nodes, eqs, comments = get_nodes_eqs_dicts(lines)
     return system_from_nodes_eqs(nodes, eqs, comments, s, prepare)
 
 def system_from_file(filename, s=None, prepare=True):
+    """Get a system from a file with pydynamo equations. If a system is given, just add this equations to it.
+
+    Parameters
+    ----------
+    filename : str
+        Files in which every pydynamo equations are written.
+    s : System
+        System to which we add changes.
+    prepare : bool
+        If True, set all constants, and functions (constants, update and init), but not special functions.
+    """
     check_file(filename)
     with open(filename, 'r') as f:
         return system_from_lines(f.readlines(), s, prepare)
 
 def system_from_fun(fun, s=None, prepare=True):
+    """Get a system from a function which lists pydynamo equations. If a system is given, just add this equations to it.
+
+    Parameters
+    ----------
+    fun : function
+        Function in which every pydynamo equations are written.
+    s : System
+        System to which we add changes.
+    prepare : bool
+        If True, set all constants, and functions (constants, update and init), but not special functions.
+
+    Examples 
+    --------
+    Just write pydynamo equations inside a function, and create a System with it:
+    >>> def custom_equations():
+    >>>     pop.i = 100
+    >>>     pop.k = pop.j /2 # Population
+    >>> custom_system = system_from_fun(custom_equations)
+    """
     lines =[l.strip()
             for l in inspect.getsource(fun).split('\n')[1:]]
     return system_from_lines(lines, s, prepare) 
 
 def check_file(filename):
+    """Check if every line in the file can be parsed by the ast module. If not, raise an error.
+    Parameters
+    ----------
+    filename : str
+        Files in which every pydynamo equations are written.
+    """
     with open(filename, 'r') as f:
         for i, l in enumerate(f.readlines()):
             try:
@@ -105,43 +191,125 @@ def check_file(filename):
                 e.lineno = i + 1
                 raise
 
-def new_cst_politic(s, cst, year, val2):
-    """Add new equations for cst, which becomes a variable changing from initval to endval during time interval from year"""
+def new_system(raw, s=None):
+    """Create a new system from either a file, a list, or a function, including pydynamo equations.
+
+    Parameters
+    ----------
+    raw : str, iterable(str) or function
+        If str, open the file named `raw` and read pydynamo equations inside (see parse_system.system_from_file`). If iterable(str), each element is a pydynamo equations (see `parse_system.system_from_lines`). If function, read pydynamo equations written inside (see `parse_system.system_from_fun`).
+
+    Returns
+    -------
+    System
+        System object.
+    """
+    if callable(raw):
+        return system_from_fun(raw, s=s)
+    elif isinstance(raw, list):
+        return system_from_lines(raw, s=s)
+    elif isinstance(raw, str):
+        return system_from_file(raw, s=s)
+
+def new_cst_politic(s, cst, date, new_value):
+    """Add a new equations to `s` for the constant `cst`, which becomes a variable changing from its former value to `new_value` after the `date`.
+    
+    Parameters
+    ----------
+    s : System
+        The system to implement the new politic.
+    cst : str
+        Constant name.
+    date : float
+        Date of politic application.
+    new_value : float
+        The new value that `cst` will take after the date `date`.
+    """
     assert cst in s.eqs['cst'], f"{cst} is not a constant"
     initval= s.eqs['cst'][cst]['line']
     del s.eqs['cst'][cst]
 
     # New equations
-
-    eq_clip = f"{cst}.k = clip({cst}2, {cst}1, time.k, {cst}_year) # {s.get_comment(cst)} " 
-    eq_year = f"{cst}_year = {year} # Date of {cst} change"
-    eq_cst1 = f"{cst}1 = {initval} # {s.get_comment({cst})} before {cst}_year"
-    eq_cst2 = f"{cst}2 = {val2} # {s.get_comment({cst})} after {cst}_year"
+    eq_clip = f"{cst}.k = clip({cst}2, {cst}1, time.k, {cst}_date) # {s.get_comment(cst)} " 
+    eq_date = f"{cst}_date = {date} # Date of {cst} change"
+    eq_cst1 = f"{cst}1 = {initval} # {s.get_comment({cst})} before {cst}_date"
+    eq_cst2 = f"{cst}2 = {new_value} # {s.get_comment({cst})} after {cst}_date"
 
     # Change calls from cst to var
     change_cst_to_var(s, cst)         
-            
-    system_from_lines([eq_clip, eq_year, eq_cst1, eq_cst2], s=s, prepare=True)
+
+    # Insert new equations
+    system_from_lines([eq_clip, eq_date, eq_cst1, eq_cst2], s=s, prepare=True)
 
 
-def new_table_politic(s, var, year, val2):
-    """Add new equations to change table from year"""
+def new_table_politic(s, var, date, new_value):
+    """Add a new equations to `s` for the table used by the variable `var`. The table changes from its former value to `new_value` after the `date`.
+    
+    Parameters
+    ----------
+    s : System
+        The system to implement the new politic.
+    var : str
+        Variable name.
+    date : float
+        Date of politic application.
+    new_value : list(float)
+        The new value that the table will take after the date `date`.
+    """
     assert f'tabhl' in s.eqs['update'][var]['args']['fun'], f"{var} hasn't tabhl function"
     table_name = s.eqs['update'][var]['args']['fun']['tabhl']['table']
     table_init_val = s.eqs['cst'][table_name]['line']
     var_line = s.eqs['update'][var]['raw_line']
-    
-    eq_table_1 = f"{table_name}1 = {table_init_val} # {s.get_comment(table_name)} before {year}"
-    eq_table_2 = f"{table_name}2 = {list(val2)} # {s.get_comment(table_name)} after {year}"    
+
+    # Define new equations
+    eq_table_1 = f"{table_name}1 = {table_init_val} # {s.get_comment(table_name)} before {date}"
+    eq_table_2 = f"{table_name}2 = {list(new_value)} # {s.get_comment(table_name)} after {date}"    
     eq_var_1 = var_line.replace(f'{var}.k', f'{var}1.k').replace(table_name, table_name + '1')
     eq_var_2 = var_line.replace(f'{var}.k', f'{var}2.k').replace(table_name, table_name + '2')
-    eq_var = f"{var}.k = clip({var}2.k, {var}1.k, time.k, {year}) # {s.get_comment(var)}"
-    
+    eq_var = f"{var}.k = clip({var}2.k, {var}1.k, time.k, {date}) # {s.get_comment(var)}"
+
+    # Insert new equations
     system_from_lines([eq_table_1, eq_table_2, eq_var_1, eq_var_2, eq_var], s=s)    
 
+def new_var_politic(s, var, date, eq2):
+    """Add a new equations to `s` for the variable `cst`, which changes from its former value to `new_value` after the `date`.
+    
+    Parameters
+    ----------
+    s : System
+        The system to implement the new politic.
+    var : str
+        Variable name.
+    date : float
+        Date of politic application.
+    new_value : str
+        The new value, written with the pydynamo syntax, that the variable `var` will take after the date `date`.
+    """
+    assert var in s.eqs['update'], f"{var} is not a variable"
+    first_line = s.eqs['update'][var]['raw_line'].split('=')[1]
+    line_init = None
+    if var in s.eqs['init']:
+        line_init = s.eqs['init'][var]['raw_line'].split('=')[1]
+    del s.eqs['update'][var]
+
+    # Define new equations
+    eq_clip = f"{var}.k = clip({var}2.k, {var}1.k, time.k, {var}_date) # {s.get_comment(var)}"
+    eq_date = f"{var}_date = {date} # Date of {var} change"
+    eq_var1 = f"{var}1.k = {first_line} # {s.get_comment(var)} before {var}_date"
+    eq_var2 = f"{var}2.k = {eq2} # {s.get_comment(var)} after {var}_date"
+    eq_init1 = ''
+    eq_init2 = ''
+    if line_init:
+        eq_init1 = f"{var}1.i = {line_init}"
+    eq_init2 = f"{var}2.i = 0"
+
+    # Insert new equations
+    system_from_lines([eq_clip, eq_date, eq_var1, eq_var2, eq_init1, eq_init2], s=s, prepare=True)
+
+
 def change_cst_to_var(s, cst):
     # TODO: FAIRE AVEC INIT
-    """Change a constant for a variable"""
+    """Change a constant for a variable. TODO: Write documentation."""
     s.nodes['cst'].remove(cst)
     for v, args in s.eqs['update'].items():
         if cst in args['args']['cst']:
@@ -154,7 +322,7 @@ def change_cst_to_var(s, cst):
 
 
 def change_var_to_cst(s, var):
-    """Change a variable for a constant"""
+    """Change a variable for a constant. TODO: Write documentation."""
     pass
     
     # s.nodes['var'].remove(var)
@@ -166,34 +334,3 @@ def change_var_to_cst(s, var):
     #         new_raw_line = re.sub(f'(?<!\w){var}(?!\w)', f'{var}.k', args['raw_line'])
     #         args['raw_line'] = new_raw_line
     #         args['line'] = new_line   
-
-def new_system(raw, s=None):
-    """returns a new System object from the data included in raw"""
-    if callable(raw):
-        return system_from_fun(raw, s=s)
-    elif isinstance(raw, list):
-        return system_from_lines(raw, s=s)
-    elif isinstance(raw, str):
-        return system_from_file(raw, s=s)
-
-def new_var_politic(s, var, year, eq2):
-    """Add new equations for var, which becomes a variable changing from initval to endval during time interval from year"""
-    assert var in s.eqs['update'], f"{var} is not a variable"
-    first_line = s.eqs['update'][var]['raw_line'].split('=')[1]
-    line_init = None
-    if var in s.eqs['init']:
-        line_init = s.eqs['init'][var]['raw_line'].split('=')[1]
-    del s.eqs['update'][var]
-
-    # New equations
-    eq_clip = f"{var}.k = clip({var}2.k, {var}1.k, time.k, {var}_year) # {s.get_comment(var)}"
-    eq_year = f"{var}_year = {year} # Date of {var} change"
-    eq_var1 = f"{var}1.k = {first_line} # {s.get_comment(var)} before {var}_year"
-    eq_var2 = f"{var}2.k = {eq2} # {s.get_comment(var)} after {var}_year"
-    eq_init1 = ''
-    eq_init2 = ''
-    if line_init:
-        eq_init1 = f"{var}1.i = {line_init}"
-    eq_init2 = f"{var}2.i = 0"
-
-    system_from_lines([eq_clip, eq_year, eq_var1, eq_var2, eq_init1, eq_init2], s=s, prepare=True)
diff --git a/pydynamo/world3/plot_utils.py b/pydynamo/world3/plot_utils.py
index df1544a9..457f7195 100644
--- a/pydynamo/world3/plot_utils.py
+++ b/pydynamo/world3/plot_utils.py
@@ -182,8 +182,8 @@ def plot_world_03(s, title=None, with_legend=False):
     scales = [scale_03_state, scale_03_life, scale_03_indices]
     try:
         time = s.time
-    except KeyError:
-        raise Exception("System must be ran before plot !")
+    except AttributeError as e:
+        raise Exception("System must be run before plot !") from e
 
     
     if with_legend:
-- 
GitLab