docs: def propagate_cols(self, col_names, target_df_name, source_df_name,
                         down=True):
          """
          Put the data for "col_name" from source_df into target_df
          Used to get "azimuth" from sample table into measurements table
          (for example).
          Note: if getting data from the sample table, don't include "sample"
          in the col_names list.  It is included automatically.
          """
          # 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)
              print("-W- Couldn't read in {} table".format(source_df_name))
              return
          # make sure col_names are all available in source table
          source_df = self.tables[source_df_name].df
          if not set(col_names).issubset(source_df.columns):
              for col in col_names[:]:
                  if col not in source_df.columns:
                      print("-W- Column '{}' isn't in {} table, skipping it".format(col, source_df_name))
                      col_names.remove(col)
          if not col_names:
              print("-W- Invalid or missing column names, could not propagate columns")
              return
          #
          if down:
              add_name = source_df_name[:-1]
              if 'measurements' in self.tables.keys():
                  self.propagate_location_to_measurements()
              elif 'specimens' in self.tables.keys():
                  self.propagate_location_to_specimens()
              else:
                  self.propagate_name_down('location', 'sites')
          else:
              add_name = target_df_name[:-1]
  
          # get dataframes for merge
          target_df = self.tables[target_df_name].df
          source_df = self.tables[source_df_name].df
          backup_source_df = source_df.copy()
          # finesse source_df to make sure it has all the right columns
          # and no unnecessary duplicates
          if source_df_name[:-1] not in source_df.columns:
              source_df[source_df_name[:-1]] = source_df.index
          source_df = source_df.drop_duplicates(inplace=False, subset=col_names + [source_df_name[:-1]])
          source_df = source_df.groupby(source_df.index, sort=False).fillna(method='ffill')
          source_df = source_df.groupby(source_df.index, sort=False).fillna(method='bfill')
          # if the groupby/fillna operation fails due to pandas bug, do the same by hand:
          if not len(source_df):
              new = []
              grouped = backup_source_df.groupby(backup_source_df.index)
              for label, group in grouped:
                  new_group = group.fillna(method="ffill")
                  new_group = new_group.fillna(method="bfill")
                  new.append(new_group)
              source_df = pd.concat(new, sort=True)
  
          # if the groupby/fillna still doesn't work, we are out of luck
          if not len(source_df):
              return target_df
          # propagate down
          if down:
              # do merge
              target_df[add_name] = target_df[add_name].astype(str)
              target_df = target_df.merge(source_df[col_names], how='left',
                                          left_on=add_name, right_index=True,
                                          suffixes=["_target", "_source"])
          # propagate up
          else:
              # do merge
              col_names.append(add_name)
              source_df[add_name] = source_df[add_name].astype(str)
              target_df = target_df.merge(source_df[col_names],
                                          how='left', left_index=True,
                                          right_on=add_name,
                                          suffixes=['_target', '_source'])
              target_df.index = target_df[add_name]
              target_df.drop([add_name + "_source", add_name + "_target"], axis=1, inplace=True)
  
          # ignore any duplicate rows
          target_df.drop_duplicates(inplace=True)
          # mess with target_df to remove un-needed merge columns
          for col in col_names:
              # if there has been a previous merge, consolidate and delete data
              if col + "_target" in target_df.columns:
                  # prioritize values from target df
                  new_arr = np.where(target_df[col + "_target"],
                                     target_df[col + "_target"],
                                     target_df[col + "_source"])
                  target_df.rename(columns={col + "_target": col}, inplace=True)
                  target_df[col] = new_arr
              if col + "_source" in target_df.columns:
                  # delete extra merge column
                  del target_df[col + "_source"]
          #
  
          # drop any duplicate rows
          target_df.drop_duplicates(inplace=True)
          self.tables[target_df_name].df = target_df
          return target_df