produce.py
  1  # SPDX-FileCopyrightText: 2020 Phillip Burgess for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """ Produce class -- handles server queries and generates seasonal produce
  6      lists for a time and place. See notes at end of file regarding data
  7      format and some design decisions.
  8  """
  9  
 10  # pylint: disable=too-many-nested-blocks, pointless-string-statement
 11  
 12  import json
 13  
 14  class Produce():
 15      """ Class to generate seasonal produce lists from server-based JSON data.
 16      """
 17  
 18      def __init__(self, url, location):
 19          """ Constructor
 20          """
 21          self.url = url
 22          self.location = location
 23          self.geo = None
 24          self.produce = None
 25  
 26  
 27      def fetch(self, magtag=None):
 28          """ Retrieves current seasonal produce data from server,
 29              does some deserializing and processing for later filtering.
 30              This is currently tied to a MagTag object -- would prefer
 31              to function with more general WiFi-type object in the future
 32              so this could work on other boards.
 33          """
 34          if self.url.startswith('file:'):
 35              # JSON data is in local file (network value is ignored)
 36              with open(self.url[6:]) as jsonfile: # Skip initial 'file:/'
 37                  json_data = json.load(jsonfile)
 38          else:
 39              # JSON data is on remote server
 40              response = magtag.network.fetch(self.url)
 41              if response.status_code == 200:
 42                  json_data = response.json()
 43  
 44          # Convert JSON geo data into hierarchical string list for later:
 45          self.geo = self.geo_ring(json_data['geo'], self.location)
 46  
 47          # Produce data is simply JSON-deserialized for later:
 48          self.produce = json_data['produce']
 49  
 50  
 51      def geo_ring(self, geo_dict, name):
 52          """ Given a dict comprising hierarchical geographic identifiers,
 53              and a most-geographically-local name to match, return a list of
 54              'concentric' geo identifier strings sorted nearest to widest.
 55              Typically called in JSON fetch above (not by user code), based
 56              on requested location.
 57          """
 58          for key in list(geo_dict.keys()):      # For each key in dict...
 59              if key == name:                    # If it matches target name,
 60                  return [key]                   # done, end search, return it
 61              for item in geo_dict[key]:         # Each item in value (list)...
 62                  if isinstance(item, str):      # If it's a string,
 63                      if item == name:           # and matches our target name
 64                          return [item] + [key]  # done, return it w/parent
 65                  elif isinstance(item, dict):   # Or, if it's a sub-dict...
 66                      sub = self.geo_ring(item, name) # Recursively scan down
 67                      if sub:                    # If item found in lower level
 68                          return sub + [key]     # Return list w key apended
 69          return None                            # No match
 70  
 71  
 72      def in_season(self, month):
 73          """ With JSON produce data previously loaded and geographic location
 74              previously set, and given a month number (1-12), return a
 75              list-of-strings of in-season produce for that time and place.
 76          """
 77          veg_list = []                              # No matches to start
 78          for veg_name in list(self.produce.keys()): # For each key ('Beets'),
 79              veg_obj = self.produce[veg_name]       # value is sub-dict
 80              for geo in self.geo:                   # Expanding geography
 81                  try:
 82                      veg_months = veg_obj[geo]      # Local veg data, if any
 83                      # Months are evaluated in pairs, as season (start, end)
 84                      for first in range(0, len(veg_months), 2):
 85                          last = first + 1
 86                          # Sometimes the season wraps around the year end...
 87                          if veg_months[last] < veg_months[first]:
 88                              if not veg_months[last] < month < veg_months[first]:
 89                                  veg_list.append(veg_name) # A match!
 90                          # Otherwise, normal month-range compare...
 91                          elif veg_months[first] <= month <= veg_months[last]:
 92                              veg_list.append(veg_name)     # A match!
 93                      break                          # Narrowest geo match, done
 94                  except KeyError:                   # No veg data for geo key,
 95                      pass                           # try next widest geo ring
 96          veg_list.sort()                            # Alphabetize list
 97          return veg_list                            # Return alphabetized list
 98  
 99  
100  """ NOTES
101  
102  Produce data is in a JSON file -- this could be fetched remotely by the
103  application of stored in a local file. Remote storage (e.g. Github) allows
104  data updates without installing new code, and allows others to contribute
105  changes. JSON isn't an ideal format for hand editing, and this code does
106  some uncouth things with the JSON format, but there's a desire to leverage
107  robust JSON parsing already available to CircuitPython, and to lessen the
108  file size and in-memory representation.
109  
110  The JSON file contains two sections, with the keys "geo" and "produce".
111  Both must be present. The "geo" section hierarchically organizes geography
112  so the code can try using very location-specific data for a food before
113  working its way up to progressively wider (but maybe less useful) regions.
114  "produce" contains food names and geographic and seasonal data for each.
115  
116  The initial dataset was derived from Farmers' Almanac, which provides seven
117  geographic regions for the continental United States, and four seasons.
118  No data is present for AK, HI, U.S. territories or other countries, nor
119  temporal resolution exceeding the provided 3-month periods -- hence the
120  mention of data updates and user contributions: the dataset will likely
121  evolve over time.
122  
123  The original seven regions, and abbreviations used in the JSON, include:
124  1. Northeast & New England ("US-NE" in JSON)
125  2. Great Lakes, Ohio Valley, Midwest ("US-MW")
126  3. Southeast ("US-SE")
127  4. North Central ("US-NC")
128  5. South Central ("US-SC")
129  6. Northwest ("US-NW")
130  7. Southwest ("US-SW")
131  
132  In the "geo" section of the JSON, there's initially a "US" dictionary key,
133  and the associated value is a list of sub-objects, each one's key is one of
134  the regions above, and values in turn are a list of state names (using 2-
135  letter postal codes) within that region...so that users can specify their
136  location by their familiar state code and not have to look up their
137  corresponding region. It's possible use further hierarchical subdivisions
138  (e.g. if a state is only a marginal match for a region, or for microclimates
139  within the same state), but this is not used in the initial dataset.
140  "geo" could've been a list of objects, and that's probably more JSON-like,
141  but instead is a single object and all keys are evaluated. It was more
142  compact this way. RAM is more precious than CPU cycles for this code, which
143  might only run once per day.)
144  The geo data is strictly hierarchical or "concentric" -- there's no support
145  for disjointed areas or crossing multiple regions (though "holes" are
146  possible) -- fully handling all that would get into complicated spatial
147  database stuff, and this is just a JSON hack, but adequate for the task.
148  
149  In the "produce" section, each foodstuff name is a dictionary key (again,
150  rather than a list or "name": elements, every key is scanned, unusual but it
151  makes for small JSON). Each associated value is another JSON object (dict),
152  and the keys there represent geographic areas (using the same abbreviations
153  defined in the "geo" section) where it's grown. Multiple areas can be
154  specified, and the code's able to use the user's geo hierarchy to return the
155  "most local" data to display, falling back on progressively broader regions
156  if needed (the sample dataset does this when most of the US has the same
157  growing seasons for a food, then exceptions are listed). The data
158  accompanying each geo key is a 2-element list with the first and last
159  month(s) when this foodstuff is grown (integers in the range 1-12, where 1
160  is January, 2 is February and so forth). It's normal sometimes that the
161  numeric value of the first month may be larger than the last, as happens for
162  winter produce that might begin late in the year and end early the next year,
163  the period "wraps around." Occasionally there will be 4 values, if there's
164  two disjoint seasons for a foodstuff (though this might just be gaps in the
165  source dataset).
166  
167  In the future, for each foodstuff, special keys (maybe beginning with an
168  understore to distinguish from geo keys) might be used to attach metadata
169  to each -- popular dishes or cuisines for it, URLs or filenames for images
170  and so forth. Not currently implemented, but that's the thought.
171  """