Download this file
#!/usr/bin/env pypy3
import sys
from collections import OrderedDict
from random import random, uniform, randrange, sample


class Composition:
    def __init__(self, fat, saturated_fat, glucides, sugars, proteins, salt):
        self.fat = fat
        self.saturated_fat = saturated_fat
        self.glucides = glucides
        self.sugars = sugars
        self.proteins = proteins
        self.salt = salt

    def params(self):
        return [self.fat, self.saturated_fat, self.glucides, self.sugars, self.proteins, self.salt]

    def __add__(self, other):
        return self.__class__(self.fat + other.fat, self.saturated_fat + other.saturated_fat,
                              self.glucides + other.glucides, self.sugars + other.sugars,
                              self.proteins + other.proteins, self.salt + other.salt)

    def __mul__(self, k):
        return self.__class__(self.fat * k, self.saturated_fat * k, self.glucides * k,
                              self.sugars * k, self.proteins * k, self.salt * k)

    def __eq__(self, other):
        return (self.fat == other.fat and
                self.saturated_fat == other.saturated_fat and
                self.glucides == other.glucides and
                self.sugars == other.sugars and
                self.proteins == other.proteins and
                self.salt == other.salt)

    def _format(self, label, value):
        s = f"{(label + ':'):12s} "
        if value:
            return s + f"{value:5.2f} g\n"
        else:
            return s + "?\n"

    def __str__(self):
        return self._format("Fat", self.fat) + \
                self._format("  Saturated", self.saturated_fat) + \
                self._format("Glucides", self.glucides) + \
                self._format("  Sugars", self.sugars) + \
                self._format("Proteins", self.proteins) + \
                self._format("Salt", self.salt)

    def __repr__(self):
        return repr([self.fat, self.saturated_fat, self.glucides, self.sugars, self.proteins, self.salt])

    @classmethod
    def zero(cls):
        return cls(0,0,0,0,0,0)


class Ingredient:
    register = {}
    def __init__(self, name, *composition):
        self.name = name
        self.composition = Composition(*composition)
        self.register[name] = self

    @classmethod
    def resolve(cls, name):
        return cls.register[name]


class Recipe:
    def __init__(self, target, ingredients, fixed_ingredients=OrderedDict()):
        self.target = target
        self.ingredients = ingredients
        self.fixed_ingredients = fixed_ingredients

    def format(self, xs, amount):
        return "\n".join(
                    f"{i + ':':16s} {q*100:4.1f} g | {q*amount:5.1f} g"
                        for i, q in zip(
                            self.ingredients + list(self.fixed_ingredients.keys()),
                            self.transform(xs) + list(self.fixed_ingredients.values()))) + \
                    "\n" + "-" * 33 + f"\nTotal:            100 g | {amount:5.1f} g\n"

    def format_composition(self, solution):
        return self.composition(self.transform(solution))

    def transform(self, xs):
        t = 1 - sum(self.fixed_ingredients.values())
        ys = []
        for x in xs:
            ys.append((t - sum(ys)) * x)
        ys.append(t - sum(ys))
        return ys

    def composition(self, qs):
        return sum(
            (i * q for i,q in zip(
                (Ingredient.resolve(i).composition for i in self.ingredients + list(self.fixed_ingredients.keys())),
                qs + list(self.fixed_ingredients.values()))),
            Composition.zero())


class ProgressBar:
    def __init__(self, steps, width=80):
        self.max_steps = steps
        self.max_width = width
        self.current_step = 0
        self.current_width = 0
        self.render()

    def render(self):
        self.clear()
        print("[" + "-" * self.current_width + " " * (self.max_width - 2 - self.current_width) + "]", end="")
        sys.stdout.flush()

    def update(self):
        self.current_step += 1
        width = round(self.current_step / self.max_steps * (self.max_width - 2))
        if width > self.current_width:
            self.current_width = width
            self.render()

    def clear(self):
        print("\r" + " " * self.max_width + "\r", end="")

Ingredient("Milk",          3.5,  2.2,   4.8,   4.8,  3.0, 0.11)
Ingredient("Oil",           100,  5.8,     0,     0,    0, 0)
Ingredient("Cream",          35, 23.4,   3.0,   3.0,  2.0, 0.07)
Ingredient("Milk powder",   1.0,  0.6,  40.0,  40.0, 47.0, 1.4)
Ingredient("Sugar",           0,    0, 100.0, 100.0,    0, 0)
Ingredient("Glucose sirup",   0,    0,  78.0,  25.7,    0, 0)
Ingredient("Egg",           9.2,  2.4,   0.3,   0.3, 12.6, 0.28)
Ingredient("Egg yolk",     29.2,  7.7,   0.2,   0.2, 15.9, 0.135)
Ingredient("Glucose",         0,    0, 100.0, 100.0,    0, 0)
Ingredient("Hazelnut",       61,  4.5,   3.4,   3.4, 14.1, 0.0025)
Ingredient("Cocoa",        21.7, 10.1,    11,     0, 18.5, 0.15)
Ingredient("Coconut milk", 21.3, 18.9,   2.8,   2.8,  2.0, 0.03)
Ingredient("Chocolate",    36.1, 22.3,  45.3,  39.5,  7.8, 0.11)


class FitnessFunction:
    def __init__(self, recipe):
        self.recipe = recipe
        self.domain_bounds = [(0.0,1.0)] * (len(self.recipe.ingredients) - 1)

    def __call__(self, xs):
        qs = self.recipe.transform(xs)
        for q1,q2 in zip(qs[:-1], qs[1:]):
            if q2 > q1:
                return float("Inf")
        return sum((x-y)**2 for x,y in zip(self.recipe.composition(qs).params(), self.recipe.target.params()) if y is not None)


class DifferentialEvolution:
    def __init__(self, func, nb_generations, domain, np=100, f=0.8, cr=0.3, logfile=None, verbose=True):
        self.func = func
        self.nb_generations = nb_generations
        self.domain = domain
        self.np = np
        self.f = f
        self.cr = cr
        if logfile:
            self.logfile = open(logfile, "w")
        else:
            self.logfile = None
        self.verbose = verbose

        self.population = [ self.random_candidate() for _ in range(self.np) ]
        self.best = (float("Inf"), [])

    def random_candidate(self):
        x = [uniform(d0, d1) for d0, d1 in self.domain]
        return (self.func(x), x)

    def adjust(self, xj, j):
        dmin, dmax = self.domain[j]
        if xj < dmin:
            return 2 * dmin - xj
        elif xj > dmax:
            return 2 * dmax - xj
        else:
            return xj

    def candidate(self, i, x):
        r = randrange(len(x))
        (fa,a), (fb,b), (fc,c) = sample(self.population[:i] + self.population[i+1:], 3)
        y = [
            (self.adjust(aj + self.f * (bj - cj), j) if random() < self.cr or j == r else xj)
            for j, (aj, bj, cj, xj) in enumerate(zip(a,b,c,x))
        ]
        return (self.func(y), y)

    def step(self):
        candidates = [self.candidate(i, x) for i,(fx,x) in enumerate(self.population)]
        self.population = [
            ((fy, y) if fy < fx else (fx, x))
            for ((fx, x), (fy, y)) in zip(self.population, candidates)
        ]

    def _log(self, iteration):
        f_best, best = self.best
        if self.logfile:
            self.logfile.write(f"{iteration} {f_best} {best}\n")
            self.logfile.flush()
        if self.verbose:
            print(iteration, f"{f_best} (" + " ".join(f"{s:.3f}" for s in best) + ")")

    def run(self):
        progress = ProgressBar(self.nb_generations)
        for generation in range(self.nb_generations):
            progress.update()
            self.step()
            best = min(self.population)
            if best[0] < self.best[0]:
                self.best = best
                self._log(generation)
        progress.clear()
        return best

def print_report(recipe, error, best):
    print("target:")
    print(recipe.target)
    print("result:")
    print(recipe.format_composition(best))
    print("error:", error)
    print("best:")
    print(recipe.format(best, 500))

if __name__ == "__main__":

    recipe = Recipe(
      Composition(None, None, 22.7, 18.3, None, 0.2),
      ["Milk", "Milk powder", "Sugar", "Glucose sirup", "Glucose"],
      OrderedDict([("Egg", 0.2)]))

    _3_kaveria = Recipe(
      Composition(8.9, 5.7, 22.7, 18.3, 7.0, 0.2),
      ["Milk", "Cream", "Milk powder", "Sugar", "Glucose sirup", "Egg", "Glucose"])

    gianduja = Recipe(
      Composition(None, None, 22.7, 18.3, None, 0.2),
      ["Milk", "Sugar", "Glucose sirup", "Milk powder", "Glucose"],
      OrderedDict([("Egg", 0.2), ("Hazelnut", 0.055), ("Cocoa", 0.055)]))

    coconut = Recipe(
      Composition(None, None, 22.7, 18.3, None, 0.2),
      ["Coconut milk", "Sugar", "Glucose sirup"],
      OrderedDict([("Egg", 0.083)]))

    # https://www.picard.fr/produits/glace-chocolat-000000000000084022.html
    chocolat = Recipe(
      Composition(11.0, 6.8, 27.0, 24.0, 5.0, 0.09),
      #["Sugar", "Egg yolk", "Milk powder"],
      ["Sugar", "Milk powder"],
      #OrderedDict([("Milk", 0.595),  ("Chocolate", 0.192), ("Cocoa", 0.011)]))
      OrderedDict([("Milk", 0.545), ("Egg", 0.1), ("Chocolate", 0.192), ("Cocoa", 0.011)]))

    fitness = FitnessFunction(chocolat)

    de = DifferentialEvolution(fitness, 40000, fitness.domain_bounds, verbose=False)
    error, best = de.run()

    print_report(fitness.recipe, error, best)