Last time we created a web service to classify ingredient quantities. This time we’ll use our web service along with the Food Data Central API to aggregate nutrient values for a recipe in a web app.

Clean-up

Before we start, we’ll do a bit of tidying up from last time. The classifier only ever matches one ingredient per line so we can flatten the output data structure. Also, as we output a jsonified dictionary from our cloud function, there’s little point in the intermediate ingredient_quantity class - we’ll just use a free function to parse straight to a dict:

def parse_ingredient_quantity(iq_str, ingredients):
	s = tokenise(iq_str)
	s = classify_ingredients(s, ingredients)
	s = classify_amounts(s)
	
	amounts = extract_typed(s, 'amount')
	number = '1'
	unit = 'default'
	for amount in amounts:
		numbers = extract_typed(amount, 'number')
		units = extract_typed(amount, 'unit')
		if len(numbers) > 0:
			number = numbers[0]
		if len(units) > 0:
			unit = first_tag(units[0])
		if len(numbers) > 0 and len(units) > 0:
			break
			
	names = [first_tag(i) for i in extract_typed(s, 'ingredient')]
	
	for name in names:
		return {
			'name': name,
			'number': float(number),
			'unit': unit,
			'componentIds': ingredients[name].component_ids,
			'unitMass': ingredients[name].unit_mass,
			'density': ingredients[name].density
		}
	
	return {
		'number': float(number),
		'unit': unit,
		'componentIds': []
	}

The web app skeleton

We'll use Ionic/Stencil to build the app. First we follow the instructions at https://stenciljs.com/docs/getting-started to create an ionic-pwa app.

Let's dispose of the tests that come (*.e2e.ts and *.spec.ts) with it because they don't work out of the box. We'll also delete the app-profile page that comes with the starter project. Now we can rewrite the app-home page.

Invoking our cloud function

We can use the fetch api to invoke our cloud function thus:

  async fetchClassified(iqStrings: string[]): Promise<IIngredientQuantity[]> {
    let res = await fetch('https://us-central1-nutrition-calculator-264017.cloudfunctions.net/nutrient-table', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({recipe: iqStrings})
    });
    return res.json();
  }

Note that we return a Promise providing an array of IIngredientQuantity interfaces, which corresponds to the dict populated by our cloud function:

interface IIngredientQuantity {
  name: string;
  number: number;
  unit: string;
  componentIds: string[];
  unitMass: number;
  density: number;
}

Invoking the Food Data Central API

We can use the same approach to invoke the FDC end-point to fetch data for the component foods:

  async fetchFood(fdcId: string): Promise<IFood> {
    let res = await fetch('https://api.nal.usda.gov/fdc/v1/food/' + fdcId + '?api_key=y14zokqvhjz7HfuBypJkYwKwknfKPDowl75n9rRt&format=abridged', {
      method: 'GET'
    });
    return res.json();
  }

We define the IFood interface with the members that we need:

interface IFood {
  fdc_id: string;
  description: string;
  foodNutrients: IFoodNutrient[];
}

This refers to the IFoodNutrient interface, which we also define with the members we need:

interface IFoodNutrient {
  name: string;
  unitName: string;
  amount: number;
}

State variables

Let's add some state variables to our AppHome class that will contain the data used to populate the UI:

  @State() loading: boolean = false;
  @State() recipe: string = '2 courgettes\n1 carrot\n1 avocado';
  @State() results: IResults = {classified: [], aggregated: []};

The loading boolean will be used to show/hide the loading indicator that gives feedback to the user that a remote fetch is in progress.

The recipe string will be bound to the contents of the text area containing the recipe to be processed.

The results object will hold the results of classification and aggregation, including some intermediate values. The IResults interface is defined thus:

interface IResults {
  classified: IIngredientQuantityEnriched[];
  aggregated: INutrientValue[];
}

The classified array contains ingredient quantities returned from the classification and enriched with additional information from FDC and the aggregation process. We define the interface as follows:

interface IIngredientQuantityEnriched extends IIngredientQuantity {
  components: IFood[];
  grams: number;
  gramsExplain: string;
  nutrientDensity: Map<string, number>;
  nutrientGrams: Map<string, number>;
}

The aggregated array contains the aggregated nutrient values as INutrientValue interfaces:

interface INutrientValue {
  name: string;
  value: number;
  unit: string;
}

Units data

To perform the conversions between units we need to paste in the contents of the units.json file that we used in our python implementation:

  units = {
    "g": {
      "factor": 1.0,
      "base_unit": "g"
    },
    "oz": {
      "factor": 28.3495,
      "base_unit": "g"
    },
    "lb": {
      "factor": 453.592,
      "base_unit": "g"
    },
    "kg": {
      "factor": 1000.0,
      "base_unit": "g"
    },
    "ml": {
      "factor": 1.0,
      "base_unit": "ml"
    },
    "cc": {
      "factor": 1.0,
      "base_unit": "ml"
    },
    "pinch": {
      "factor": 0.73992,
      "base_unit": "ml"
    },
    "tsp": {
      "factor": 5.91939,
      "base_unit": "ml"
    },
    "tbsp": {
      "factor": 17.7582,
      "base_unit": "ml"
    },
    "floz": {
      "factor": 28.4131,
      "base_unit": "ml"
    },
    "cup": {
      "factor": 284.131,
      "base_unit": "ml"
    },
    "pt": {
      "factor": 568.261,
      "base_unit": "ml"
    },
    "dl": {
      "factor": 100.0,
      "base_unit": "ml"
    },
    "l": {
      "factor": 1000.0,
      "base_unit": "ml"
    },
    "gal": {
      "factor": 4546.09,
      "base_unit": "ml"
    }
  };

Render function

Now we can implement the render function to render a text area containing the recipe ingredients, a button to press to start processing and either a loading indicator or a nested list of results depending on whether the processing is complete or not:

  render() {
    return [
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Veebe</ion-title>
        </ion-toolbar>
      </ion-header>,

      <ion-content class="ion-padding">

        <ion-textarea value={this.recipe} onIonChange={e => {this.recipe = e.detail.value}}/>

        <ion-button onClick={() => this.updateClicked()}>Update</ion-button>

        {this.loading ? <p>Loading<br/><ion-spinner name="dots"/></p> :
        <div>
          {this.results.aggregated.length > 0 ? <ul>
            <li>Nutrient Totals
              <ul>{this.results.aggregated.map(n => <li>{n.name} = {n.value} {n.unit}</li>)}</ul>
            </li>
            <li>Nutrients by Ingredient Quantity
              <ul>{this.results.classified.map(iq =>
                <li>{iq.number} {iq.unit == 'default' ? 'x' : iq.unit} {iq.name}
                  <ul>
                    <li>Mass = {iq.gramsExplain} = {iq.grams} g</li>
                    <li>Components
                      <ul>
                        {iq.components.map(c => <li>{c.fdc_id} {c.description}</li>)}
                      </ul>
                    </li>
                    <li>Nutrient Values
                      <ul>
                        {this.results.aggregated.map(n => <li>{n.name} = {iq.nutrientDensity.get(n.name)} {n.unit}/100g / 100 * {iq.grams} g = {iq.nutrientGrams.get(n.name)} {n.unit}</li>)}
                      </ul>
                    </li>
                  </ul>
                </li>)}
              </ul>
            </li>
          </ul> : null}
        </div>}
      </ion-content>
    ];
  }

Processing

Now we can implement our processing. First let's implement a handler for the button press that enables the loading indicator, waits for the processing to complete and then disables the loading indicator:

  async updateClicked() {
    this.loading = true;
    await this.classifyAndAggregate();
    this.loading = false;
  }

Now we get to the main part - implementing the classifyAndAggregate function:

  async classifyAndAggregate() {
    // ...
  }

Firstly, we invoke the cloud function to classify the raw ingredient strings:

    let classified = (await this.fetchClassified(this.recipe.split('\n'))) as IIngredientQuantityEnriched[];

Then we fetch all the food components from FDC:

    await Promise.all(
      classified.map(async iq => {
        iq.components = await Promise.all(
          iq.componentIds.map(cid => this.fetchFood(cid)))
      }));

Then we calculate the mass in grams of each ingredient and generate a string describing the calculation (gramsExplain). There are 5 cases:

  • There are no components (unrecognised ingredient) - mass = 0.0
  • Unit is default - mass = number * unit mass
  • Unit has base unit g - mass = number * unit factor
  • Unit has base unit ml - mass = number * unit factor * density
  • Unit is unknown - mass = 0.0
    for (let iq of classified) {
      if (iq.componentIds.length == 0) {
        iq.grams = 0.0;
        iq.gramsExplain = 'unrecognised ingredient';
        continue;
      }

      if (iq.unit == 'default') {
        iq.grams = iq.number * iq.unitMass;
        iq.gramsExplain = iq.number + ' * ' + iq.unitMass + 'g';
      }
      else if (this.units[iq.unit]) {
        if (this.units[iq.unit].base_unit == 'g') {
          iq.grams = iq.number * this.units[iq.unit].factor;
          iq.gramsExplain = iq.number + ' ' + iq.unit + ' * ' + this.units[iq.unit].factor + ' g/' + iq.unit;
        }
        else {
          iq.grams = iq.number * this.units[iq.unit].factor * iq.density;
          iq.gramsExplain = iq.number + ' ' + iq.unit + ' * ' + this.units[iq.unit].factor + ' ml/' + iq.unit + ' * ' + iq.density + ' g/ml';
        }
      }
      else {
        iq.grams = 0.0;
        iq.gramsExplain = 'unrecognised unit';
      }
    }

Next we count up the number of ingredients featuring each nutrient and select the nutrients that feature in all ingredients:

    let componentCount = 0;
    let nutrientCounts: Map<string, number> = new Map();
    let nutrientUnits: Map<string, string> = new Map();
    for (let iq of classified)
      for (let c of iq.components) {
        componentCount++;
        for (let n of c.foodNutrients) {
          nutrientCounts.set(n.name, nutrientCounts.get(n.name) ? nutrientCounts.get(n.name) + 1 : 1);
          nutrientUnits.set(n.name, n.unitName);
        }
      }

    let aggregated: INutrientValue[] = [];
    for (let [n, c] of nutrientCounts.entries())
      if (c == componentCount)
        aggregated.push({name: n, value: 0.0, unit: nutrientUnits.get(n)});

Finally we aggregate the number of grams of each nutrient contained in each ingredient over all the ingredients in the recipe and update the state variables to display the new data:

    for (let iq of classified) {
      iq.nutrientDensity = new Map();
      iq.nutrientGrams = new Map();
      for (let n of aggregated) {
        let sum = 0.0;
        for (let c of iq.components) {
          for (let n2 of c.foodNutrients)
            if (n.name == n2.name)
              sum += n2.amount;
        }
        const nutrientDensity = sum / iq.components.length;
        iq.nutrientDensity.set(n.name, nutrientDensity);
        const nutrientGrams = nutrientDensity / 100 * iq.grams;
        iq.nutrientGrams.set(n.name, nutrientGrams);
        n.value += nutrientGrams;
      }
    }

    this.results = { classified: classified, aggregated: aggregated };

That's it! We have a fairly basic and rather ugly app that takes a free text list of recipe ingredients, matches them to FDC foods and outputs the aggregated nutrient values for all the common nutrients along with a description of the calculations.

Next time

We'll think about how to make this a bit more presentable.