docs: def propagate_ages(self):
          """
          Mine ages table for any age data, and write it into
          specimens, samples, sites, locations tables.
          Do not overwrite existing age data.
          """
          # if there is no age table, skip
          if 'ages' not in self.tables:
              return
          # if age table has no data, skip
          if not len(self.tables['ages'].df):
              return
          # get levels in age table
          self.get_age_levels()
          # if age levels could not be determined, skip
          if not "level" in self.tables["ages"].df.columns:
              return
          if not any(self.tables["ages"].df["level"]):
              return
          # go through each level of age data
          for level in self.tables['ages'].df['level'].unique():
              table_name = level + 's'
              age_headers = self.data_model.get_group_headers(table_name, 'Age')
              # find age headers that are actually in table
              actual_age_headers = list(set(self.tables[table_name].df.columns).intersection(age_headers))
              # find site age headers that are available in ages table
              available_age_headers = list(set(self.tables['ages'].df.columns).intersection(age_headers))
              # fill in all available age info to all rows
              self.tables[table_name].front_and_backfill(actual_age_headers)
              # add any available headers to table
              add_headers = set(available_age_headers).difference(actual_age_headers)
              for header in add_headers:
                  self.tables[table_name].df[header] = None
              # propagate values from ages into table
              def move_values(ser, level, available_headers):
                  name = ser.name
                  cond1 = self.tables['ages'].df[level] == name
                  cond2 = self.tables['ages'].df['level'] == level
                  mask = cond1 & cond2
                  sli = self.tables['ages'].df[mask]
                  if len(sli):
                      return list(sli[available_headers].values[0])
                  return [None] * len(available_headers)
  
              res = self.tables[table_name].df.apply(move_values, axis=1,
                                                     args=[level, available_age_headers])
              # fill in table with values gleaned from ages
              new_df = pd.DataFrame(data=list(res.values), index=res.index,
                                    columns=available_age_headers)
              age_values = np.where(self.tables[table_name].df[available_age_headers],
                                    self.tables[table_name].df[available_age_headers],
                                    new_df)
              self.tables[table_name].df[available_age_headers] = age_values
          #
          # put age_high, age_low into locations table
          print("-I- Adding age_high and age_low to locations table based on minimum/maximum ages found in sites table")
          self.propagate_min_max_up(cols=['age'], target_df_name='locations',
                                    source_df_name='sites')