docs: def propagate_min_max_up(self, cols=['age'],
                               target_df_name='locations',
                               source_df_name='sites',
                               min_suffix='low',
                               max_suffix='high'):
          """
          Take minimum/maximum values for a set of columns in source_df,
          and apply them to the target table.
          This method won't overwrite values in the target table, it will only
          supply values where they are missing.
  
          Parameters
          ----------
          cols : list-like
              list of columns to propagate, default ['age']
          target_df_name : str
              name of table to propagate values into, default 'locations'
          source_df_name:
              name of table to propagate values from, default 'sites'
          min_suffix : str
              suffix for minimum value, default 'low'
          max_suffix : str
              suffix for maximum value, default 'high'
  
          Returns
          ---------
          target_df : MagicDataFrame
              updated MagicDataFrame with propagated values
          """
          # make sure target/source table are appropriate
          target_ind = self.ancestry.index(target_df_name)
          source_ind = self.ancestry.index(source_df_name)
          if target_ind - source_ind != 1:
              print('-W- propagate_min_max_up only works with tables that are spaced one apart, i.e. sites and samples.')
              print('    Source table must be lower in the hierarchy than the target table.')
              print('    You have provided "{}" as the target table and "{}" as the source table.'.format(target_df_name, source_df_name))
              return None
          # make sure target table is read in
          if target_df_name not in self.tables:
              self.add_magic_table(target_df_name)
          if target_df_name not in self.tables:
              print("-W- Couldn't read in {} table".format(target_df_name))
              return
          # make sure source table is read in
          if source_df_name not in self.tables:
              self.add_magic_table(source_df_name)
          if source_df_name not in self.tables:
              print("-W- Couldn't read in {} table".format(source_df_name))
              return
          # get tables
          target_df = self.tables[target_df_name]
          source_df = self.tables[source_df_name]
          target_name = target_df_name[:-1]
          # find and propagate min/max for each col in cols
          for col in cols:
              if col not in source_df.df.columns:
                  print('-W- {} table is missing "{}" column, skipping'.format(source_df_name, col))
                  continue
              min_col = col + "_" + min_suffix
              max_col = col + "_" + max_suffix
              # add min/max cols to target_df if missing
              if min_col not in target_df.df.columns:
                  target_df.df[min_col] = None
              if max_col not in target_df.df.columns:
                  target_df.df[max_col] = None
              # get min/max from source
              if target_name not in source_df.df.columns:
                  print('-W- {} table missing {} column, cannot propagate age info'.format(target_name, source_df_name))
                  return
              # make sure source is appropriately filled
              source = source_df.front_and_backfill([col], inplace=False)
              # add target_name back into front/backfilled source
              source[target_name] = source_df.df[target_name]
              grouped = source[[col, target_name]].groupby(target_name)
              if len(grouped):
                  minimum, maximum = grouped.min(), grouped.max()
                  minimum = minimum.reindex(target_df.df.index)
                  maximum = maximum.reindex(target_df.df.index)
                  # update target_df without overwriting existing values
                  cond_min = target_df.df[min_col].apply(not_null)
                  cond_max = target_df.df[max_col].apply(not_null)
                  #
                  target_df.df[min_col] = np.where(cond_min,
                                                   target_df.df[min_col],
                                                   minimum[col])
                  target_df.df[max_col] = np.where(cond_max,
                                                   target_df.df[max_col],
                                                   maximum[col])
          # update contribution
          self.tables[target_df_name] = target_df
          return target_df