/ MagTag_Seasonal_Produce / produce.py
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 """