Spaces:
Sleeping
Sleeping
| """ | |
| Soap Lab — Chemistry Engine | |
| Day 1: SAP values, fatty acid profiles, lye calculator, soap quality scorer. | |
| SAP values sourced from HSCG Lye Calculator (soapguild.org) — the professional | |
| industry standard. Each oil has a NaOH SAP range (low, high) and the average | |
| used for calculations. KOH values = NaOH * 1.403. | |
| Fatty acid profiles are used for: | |
| - Substitution matching (find oils with similar profiles) | |
| - Soap quality scoring (hardness, cleansing, conditioning, lather, INS) | |
| - Vegan flagging | |
| """ | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| # --------------------------------------------------------------------------- | |
| # Data structures | |
| # --------------------------------------------------------------------------- | |
| class Oil: | |
| name: str | |
| naoh_sap_low: float # lowest acceptable SAP value (NaOH) | |
| naoh_sap_high: float # highest acceptable SAP value (NaOH) | |
| # Fatty acid percentages (approximate midpoints, sum ~100) | |
| lauric: float = 0.0 # hardness, big bubbles, cleansing | |
| myristic: float = 0.0 # hardness, big bubbles, cleansing | |
| palmitic: float = 0.0 # hardness, stable lather | |
| stearic: float = 0.0 # hardness, stable lather | |
| ricinoleic: float = 0.0 # conditioning, fluffy lather (castor) | |
| oleic: float = 0.0 # conditioning, creamy lather | |
| linoleic: float = 0.0 # conditioning, skin feel | |
| linolenic: float = 0.0 # conditioning (goes rancid fast if high) | |
| vegan: bool = True # False for animal-derived oils | |
| def naoh_sap(self) -> float: | |
| """Average SAP value used in calculations.""" | |
| return round((self.naoh_sap_low + self.naoh_sap_high) / 2, 4) | |
| def koh_sap(self) -> float: | |
| """KOH SAP value (NaOH * 1.403).""" | |
| return round(self.naoh_sap * 1.403, 4) | |
| def iodine_value(self) -> float: | |
| """Estimated iodine value from fatty acid profile.""" | |
| return round( | |
| self.linoleic * 1.81 + | |
| self.linolenic * 2.74 + | |
| self.oleic * 0.899 + | |
| self.ricinoleic * 0.86, | |
| 1 | |
| ) | |
| def ins(self) -> float: | |
| """INS value (iodine number subtracted from SAP*1000). Target: 136-165.""" | |
| return round(self.naoh_sap * 1000 - self.iodine_value, 1) | |
| # --------------------------------------------------------------------------- | |
| # Oil database (SAP from HSCG; fatty acids from literature) | |
| # --------------------------------------------------------------------------- | |
| OILS: dict[str, Oil] = { | |
| # ---- Tropical / hard oils ---- | |
| "coconut oil": Oil( | |
| name="Coconut Oil", | |
| naoh_sap_low=0.178, naoh_sap_high=0.185, | |
| lauric=47, myristic=18, palmitic=9, stearic=3, | |
| oleic=8, linoleic=2, | |
| ), | |
| "palm kernel oil": Oil( | |
| name="Palm Kernel Oil", | |
| naoh_sap_low=0.175, naoh_sap_high=0.183, | |
| lauric=48, myristic=16, palmitic=8, stearic=3, | |
| oleic=15, linoleic=2, | |
| ), | |
| "babassu palm oil": Oil( | |
| name="Babassu Palm Oil", | |
| naoh_sap_low=0.170, naoh_sap_high=0.178, | |
| lauric=44, myristic=16, palmitic=9, stearic=4, | |
| oleic=17, linoleic=3, | |
| ), | |
| "palm oil": Oil( | |
| name="Palm Oil", | |
| naoh_sap_low=0.138, naoh_sap_high=0.143, | |
| palmitic=44, stearic=5, oleic=39, linoleic=10, | |
| ), | |
| "palm stearin": Oil( | |
| name="Palm Stearin", | |
| naoh_sap_low=0.145, naoh_sap_high=0.156, | |
| palmitic=54, stearic=6, oleic=31, linoleic=9, | |
| ), | |
| "cohune nut oil": Oil( | |
| name="Cohune Nut Oil", | |
| naoh_sap_low=0.170, naoh_sap_high=0.178, | |
| lauric=45, myristic=15, palmitic=8, stearic=4, | |
| oleic=18, linoleic=3, | |
| ), | |
| # ---- Animal fats ---- | |
| "lard": Oil( | |
| name="Lard", | |
| naoh_sap_low=0.135, naoh_sap_high=0.140, | |
| palmitic=26, stearic=14, oleic=45, linoleic=10, | |
| vegan=False, | |
| ), | |
| "tallow (beef)": Oil( | |
| name="Tallow (Beef)", | |
| naoh_sap_low=0.138, naoh_sap_high=0.143, | |
| palmitic=27, stearic=20, oleic=42, linoleic=4, | |
| vegan=False, | |
| ), | |
| "beeswax": Oil( | |
| name="Beeswax", | |
| naoh_sap_low=0.066, naoh_sap_high=0.069, | |
| palmitic=25, stearic=10, oleic=35, | |
| vegan=False, | |
| ), | |
| "emu oil": Oil( | |
| name="Emu Oil", | |
| naoh_sap_low=0.136, naoh_sap_high=0.140, | |
| palmitic=20, stearic=10, oleic=50, linoleic=15, | |
| vegan=False, | |
| ), | |
| "butterfat": Oil( | |
| name="Butterfat", | |
| naoh_sap_low=0.178, naoh_sap_high=0.192, | |
| lauric=3, myristic=11, palmitic=27, stearic=12, | |
| oleic=29, linoleic=2, | |
| vegan=False, | |
| ), | |
| "ghee butter (buffalo milk)": Oil( | |
| name="Ghee Butter (Buffalo Milk)", | |
| naoh_sap_low=0.178, naoh_sap_high=0.192, | |
| myristic=11, palmitic=30, stearic=13, | |
| oleic=30, linoleic=2, | |
| vegan=False, | |
| ), | |
| "goose fat": Oil( | |
| name="Goose Fat", | |
| naoh_sap_low=0.136, naoh_sap_high=0.139, | |
| palmitic=22, stearic=7, oleic=57, linoleic=9, | |
| vegan=False, | |
| ), | |
| "horse fat": Oil( | |
| name="Horse Fat", | |
| naoh_sap_low=0.136, naoh_sap_high=0.139, | |
| palmitic=20, stearic=6, oleic=52, linoleic=16, | |
| vegan=False, | |
| ), | |
| "neetsfoot oil": Oil( | |
| name="Neetsfoot Oil", | |
| naoh_sap_low=0.136, naoh_sap_high=0.139, | |
| palmitic=27, stearic=5, oleic=64, linoleic=2, | |
| vegan=False, | |
| ), | |
| # ---- Liquid oils ---- | |
| "olive oil": Oil( | |
| name="Olive Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.137, | |
| palmitic=13, stearic=3, oleic=71, linoleic=10, | |
| ), | |
| "olive oil (pomace)": Oil( | |
| name="Olive Oil (Pomace)", | |
| naoh_sap_low=0.133, naoh_sap_high=0.137, | |
| palmitic=14, stearic=3, oleic=74, linoleic=8, | |
| ), | |
| "olive oil (virgin)": Oil( | |
| name="Olive Oil (Virgin)", | |
| naoh_sap_low=0.133, naoh_sap_high=0.137, | |
| palmitic=12, stearic=3, oleic=72, linoleic=10, | |
| ), | |
| "castor oil": Oil( | |
| name="Castor Oil", | |
| naoh_sap_low=0.127, naoh_sap_high=0.130, | |
| ricinoleic=90, oleic=4, linoleic=4, | |
| ), | |
| "sunflower seed oil": Oil( | |
| name="Sunflower Seed Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=7, stearic=5, oleic=30, linoleic=55, | |
| ), | |
| "sunflower seed oil (high oleic)": Oil( | |
| name="Sunflower Seed Oil (High Oleic)", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=4, stearic=4, oleic=83, linoleic=5, | |
| ), | |
| "canola oil (low erucic)": Oil( | |
| name="Canola Oil", | |
| naoh_sap_low=0.131, naoh_sap_high=0.135, | |
| palmitic=4, stearic=2, oleic=62, linoleic=22, linolenic=10, | |
| ), | |
| "soybean oil": Oil( | |
| name="Soybean Oil", | |
| naoh_sap_low=0.135, naoh_sap_high=0.138, | |
| palmitic=11, stearic=4, oleic=24, linoleic=54, linolenic=7, | |
| ), | |
| "corn oil": Oil( | |
| name="Corn Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=11, stearic=2, oleic=28, linoleic=58, | |
| ), | |
| "rice bran oil": Oil( | |
| name="Rice Bran Oil", | |
| naoh_sap_low=0.127, naoh_sap_high=0.130, | |
| palmitic=20, stearic=2, oleic=44, linoleic=32, | |
| ), | |
| "hempseed oil": Oil( | |
| name="Hempseed Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=6, stearic=2, oleic=12, linoleic=57, linolenic=20, | |
| ), | |
| "linseed (flaxseed) oil": Oil( | |
| name="Linseed (Flaxseed) Oil", | |
| naoh_sap_low=0.134, naoh_sap_high=0.138, | |
| palmitic=5, stearic=4, oleic=21, linoleic=14, linolenic=53, | |
| ), | |
| "grape seed oil": Oil( | |
| name="Grape Seed Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=7, stearic=4, oleic=16, linoleic=70, | |
| ), | |
| "peanut oil": Oil( | |
| name="Peanut Oil", | |
| naoh_sap_low=0.135, naoh_sap_high=0.139, | |
| palmitic=11, stearic=3, oleic=48, linoleic=32, | |
| ), | |
| "sesame seed oil": Oil( | |
| name="Sesame Seed Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=9, stearic=6, oleic=41, linoleic=43, | |
| ), | |
| "safflower oil (high linoleic)": Oil( | |
| name="Safflower Oil (High Linoleic)", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=7, stearic=2, oleic=13, linoleic=77, | |
| ), | |
| "safflower oil (high oleic)": Oil( | |
| name="Safflower Oil (High Oleic)", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=6, stearic=2, oleic=75, linoleic=15, | |
| ), | |
| "neem oil": Oil( | |
| name="Neem Oil", | |
| naoh_sap_low=0.135, naoh_sap_high=0.138, | |
| palmitic=16, stearic=20, oleic=45, linoleic=14, | |
| ), | |
| # ---- Luxury / specialty oils ---- | |
| "almond oil, sweet": Oil( | |
| name="Almond Oil, Sweet", | |
| naoh_sap_low=0.134, naoh_sap_high=0.138, | |
| palmitic=7, stearic=2, oleic=70, linoleic=20, | |
| ), | |
| "apricot kernel oil": Oil( | |
| name="Apricot Kernel Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.137, | |
| palmitic=5, stearic=1, oleic=66, linoleic=27, | |
| ), | |
| "avocado oil": Oil( | |
| name="Avocado Oil", | |
| naoh_sap_low=0.132, naoh_sap_high=0.136, | |
| palmitic=17, stearic=2, oleic=65, linoleic=13, | |
| ), | |
| "jojoba oil (golden, unrefined)": Oil( | |
| name="Jojoba Oil (Golden, Unrefined)", | |
| naoh_sap_low=0.066, naoh_sap_high=0.069, | |
| oleic=71, linoleic=0, # technically a wax ester | |
| ), | |
| "jojoba oil (lite, refined)": Oil( | |
| name="Jojoba Oil (Lite, Refined)", | |
| naoh_sap_low=0.066, naoh_sap_high=0.069, | |
| oleic=71, | |
| ), | |
| "evening primrose oil": Oil( | |
| name="Evening Primrose Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=6, stearic=2, oleic=11, linoleic=73, linolenic=8, | |
| ), | |
| "rosehip seed oil": Oil( | |
| name="Rosehip Seed Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=4, stearic=2, oleic=14, linoleic=46, linolenic=33, | |
| ), | |
| "meadowfoam seed oil": Oil( | |
| name="Meadowfoam Seed Oil", | |
| naoh_sap_low=0.097, naoh_sap_high=0.100, | |
| oleic=65, linoleic=2, | |
| ), | |
| "karanja (pengam) oil": Oil( | |
| name="Karanja Oil", | |
| naoh_sap_low=0.135, naoh_sap_high=0.138, | |
| palmitic=11, stearic=7, oleic=52, linoleic=16, | |
| ), | |
| "walnut oil": Oil( | |
| name="Walnut Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=7, stearic=3, oleic=23, linoleic=58, linolenic=9, | |
| ), | |
| "filbert (hazelnut) oil": Oil( | |
| name="Filbert (Hazelnut) Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=5, stearic=3, oleic=77, linoleic=13, | |
| ), | |
| "pumpkin seed oil": Oil( | |
| name="Pumpkin Seed Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.135, | |
| palmitic=12, stearic=6, oleic=34, linoleic=45, | |
| ), | |
| "wheat germ oil": Oil( | |
| name="Wheat Germ Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=16, stearic=1, oleic=20, linoleic=55, linolenic=6, | |
| ), | |
| "tamanu oil": Oil( | |
| name="Tamanu (Domba) Oil", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=13, stearic=13, oleic=41, linoleic=29, | |
| ), | |
| # ---- Butters ---- | |
| "sheanut butter": Oil( | |
| name="Sheanut Butter", | |
| naoh_sap_low=0.127, naoh_sap_high=0.131, | |
| palmitic=6, stearic=40, oleic=48, linoleic=5, | |
| ), | |
| "cocoa butter": Oil( | |
| name="Cocoa Butter", | |
| naoh_sap_low=0.136, naoh_sap_high=0.140, | |
| palmitic=25, stearic=36, oleic=35, linoleic=3, | |
| ), | |
| "mango butter (refined)": Oil( | |
| name="Mango Butter (Refined)", | |
| naoh_sap_low=0.128, naoh_sap_high=0.133, | |
| palmitic=6, stearic=40, oleic=46, linoleic=5, | |
| ), | |
| "illipe butter": Oil( | |
| name="Illipe Butter", | |
| naoh_sap_low=0.133, naoh_sap_high=0.138, | |
| palmitic=18, stearic=43, oleic=34, linoleic=3, | |
| ), | |
| "kokum butter": Oil( | |
| name="Kokum Butter", | |
| naoh_sap_low=0.133, naoh_sap_high=0.136, | |
| palmitic=4, stearic=54, oleic=39, linoleic=2, | |
| ), | |
| "sal (shorea) fat": Oil( | |
| name="Sal (Shorea) Fat", | |
| naoh_sap_low=0.132, naoh_sap_high=0.136, | |
| palmitic=7, stearic=42, oleic=44, linoleic=3, | |
| ), | |
| "mowrah butter": Oil( | |
| name="Mowrah Butter", | |
| naoh_sap_low=0.134, naoh_sap_high=0.138, | |
| palmitic=22, stearic=22, oleic=40, linoleic=14, | |
| ), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Core calculator | |
| # --------------------------------------------------------------------------- | |
| def calculate_lye( | |
| recipe: dict[str, float], # {oil_key: weight_grams} | |
| lye_type: str = "naoh", # "naoh", "koh", or "combo" | |
| superfat: float = 5.0, # superfat percentage (0-30) | |
| koh_purity: float = 90.0, # KOH purity % (usually 90%) | |
| water_ratio: float = 0.38, # water as ratio of oil weight | |
| naoh_ratio: float = 0.5, # for combo: fraction that is NaOH | |
| ) -> dict: | |
| """ | |
| Calculate lye amounts for a soap recipe. | |
| Returns a dict with all calculated values. | |
| """ | |
| if not recipe: | |
| return {"error": "No oils provided."} | |
| unknown = [k for k in recipe if k not in OILS] | |
| if unknown: | |
| return {"error": f"Unknown oil(s): {', '.join(unknown)}"} | |
| total_oil_weight = sum(recipe.values()) | |
| superfat_multiplier = 1 - (superfat / 100) | |
| naoh_total = 0.0 | |
| koh_total = 0.0 | |
| oil_details = [] | |
| for oil_key, weight in recipe.items(): | |
| oil = OILS[oil_key] | |
| naoh_needed = weight * oil.naoh_sap * superfat_multiplier | |
| koh_needed = weight * oil.koh_sap * superfat_multiplier | |
| naoh_total += naoh_needed | |
| koh_total += koh_needed | |
| oil_details.append({ | |
| "name": oil.name, | |
| "weight_g": round(weight, 1), | |
| "pct_of_recipe": round(weight / total_oil_weight * 100, 1), | |
| "naoh_sap": oil.naoh_sap, | |
| "naoh_sap_range": f"{oil.naoh_sap_low}–{oil.naoh_sap_high}", | |
| "naoh_needed_g": round(naoh_needed, 2), | |
| "iodine_value": oil.iodine_value, | |
| "ins": oil.ins, | |
| }) | |
| water_g = round(total_oil_weight * water_ratio, 1) | |
| result = { | |
| "total_oil_weight_g": round(total_oil_weight, 1), | |
| "superfat_pct": superfat, | |
| "water_g": water_g, | |
| "oil_details": oil_details, | |
| } | |
| if lye_type == "naoh": | |
| result["naoh_g"] = round(naoh_total, 2) | |
| result["lye_type"] = "NaOH (Sodium Hydroxide)" | |
| elif lye_type == "koh": | |
| # Adjust for purity | |
| result["koh_g"] = round(koh_total / (koh_purity / 100), 2) | |
| result["koh_purity_pct"] = koh_purity | |
| result["lye_type"] = "KOH (Potassium Hydroxide)" | |
| elif lye_type == "combo": | |
| naoh_portion = naoh_total * naoh_ratio * superfat_multiplier | |
| koh_portion = koh_total * (1 - naoh_ratio) * superfat_multiplier / (koh_purity / 100) | |
| result["naoh_g"] = round(naoh_portion, 2) | |
| result["koh_g"] = round(koh_portion, 2) | |
| result["lye_type"] = "Combination NaOH + KOH" | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Soap quality scorer | |
| # --------------------------------------------------------------------------- | |
| def score_recipe(recipe: dict[str, float]) -> dict: | |
| """ | |
| Calculate weighted soap quality scores for a recipe. | |
| Industry targets: | |
| Hardness: 29–54 (palmitic + stearic + lauric + myristic) | |
| Cleansing: 12–22 (lauric + myristic) | |
| Conditioning: 44–69 (oleic + linoleic + linolenic + ricinoleic) | |
| Bubbly lather: 14–46 (lauric + myristic + ricinoleic) | |
| Creamy lather: 16–48 (palmitic + stearic + ricinoleic) | |
| INS: 136–165 | |
| Iodine: 41–70 | |
| """ | |
| if not recipe: | |
| return {} | |
| total = sum(recipe.values()) | |
| if total == 0: | |
| return {} | |
| scores = {k: 0.0 for k in [ | |
| "hardness", "cleansing", "conditioning", | |
| "bubbly_lather", "creamy_lather", "ins", "iodine_value" | |
| ]} | |
| for oil_key, weight in recipe.items(): | |
| if oil_key not in OILS: | |
| continue | |
| oil = OILS[oil_key] | |
| pct = weight / total # weight fraction | |
| scores["hardness"] += pct * (oil.palmitic + oil.stearic + oil.lauric + oil.myristic) | |
| scores["cleansing"] += pct * (oil.lauric + oil.myristic) | |
| scores["conditioning"] += pct * (oil.oleic + oil.linoleic + oil.linolenic + oil.ricinoleic) | |
| scores["bubbly_lather"] += pct * (oil.lauric + oil.myristic + oil.ricinoleic) | |
| scores["creamy_lather"] += pct * (oil.palmitic + oil.stearic + oil.ricinoleic) | |
| scores["ins"] += pct * oil.ins | |
| scores["iodine_value"] += pct * oil.iodine_value | |
| targets = { | |
| "hardness": (29, 54, "Hardness"), | |
| "cleansing": (12, 22, "Cleansing"), | |
| "conditioning": (44, 69, "Conditioning"), | |
| "bubbly_lather": (14, 46, "Bubbly lather"), | |
| "creamy_lather": (16, 48, "Creamy lather"), | |
| "ins": (136, 165, "INS"), | |
| "iodine_value": (41, 70, "Iodine value"), | |
| } | |
| result = {} | |
| for key, (low, high, label) in targets.items(): | |
| val = round(scores[key], 1) | |
| if val < low: | |
| status = "low" | |
| elif val > high: | |
| status = "high" | |
| else: | |
| status = "ok" | |
| result[key] = { | |
| "label": label, | |
| "value": val, | |
| "target_low": low, | |
| "target_high": high, | |
| "status": status, | |
| } | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Substitution finder | |
| # --------------------------------------------------------------------------- | |
| def find_substitutes( | |
| oil_key: str, | |
| n: int = 3, | |
| vegan_only: bool = False, | |
| ) -> list[dict]: | |
| """ | |
| Find the n most similar oils to oil_key based on fatty acid profile. | |
| Uses Euclidean distance across the 8 fatty acid dimensions. | |
| """ | |
| if oil_key not in OILS: | |
| return [] | |
| target = OILS[oil_key] | |
| candidates = [] | |
| for key, oil in OILS.items(): | |
| if key == oil_key: | |
| continue | |
| if vegan_only and not oil.vegan: | |
| continue | |
| # Euclidean distance across fatty acid profile | |
| dist = sum([ | |
| (oil.lauric - target.lauric) ** 2, | |
| (oil.myristic - target.myristic) ** 2, | |
| (oil.palmitic - target.palmitic) ** 2, | |
| (oil.stearic - target.stearic) ** 2, | |
| (oil.ricinoleic - target.ricinoleic) ** 2, | |
| (oil.oleic - target.oleic) ** 2, | |
| (oil.linoleic - target.linoleic) ** 2, | |
| (oil.linolenic - target.linolenic) ** 2, | |
| ]) ** 0.5 | |
| candidates.append({ | |
| "oil_key": key, | |
| "name": oil.name, | |
| "distance": round(dist, 2), | |
| "vegan": oil.vegan, | |
| "naoh_sap": oil.naoh_sap, | |
| }) | |
| candidates.sort(key=lambda x: x["distance"]) | |
| return candidates[:n] | |
| # --------------------------------------------------------------------------- | |
| # Fatty acid profile summary (for AI context injection) | |
| # --------------------------------------------------------------------------- | |
| def profile_summary(recipe: dict[str, float]) -> dict: | |
| """ | |
| Returns a plain-English-ready summary of the recipe's fatty acid makeup, | |
| vegan status, and any flags. Used to feed the AI explanation layer. | |
| """ | |
| if not recipe: | |
| return {} | |
| total = sum(recipe.values()) | |
| non_vegan = [OILS[k].name for k in recipe if k in OILS and not OILS[k].vegan] | |
| fatty_acids = { | |
| "lauric": 0, "myristic": 0, "palmitic": 0, "stearic": 0, | |
| "ricinoleic": 0, "oleic": 0, "linoleic": 0, "linolenic": 0, | |
| } | |
| for oil_key, weight in recipe.items(): | |
| if oil_key not in OILS: | |
| continue | |
| oil = OILS[oil_key] | |
| pct = weight / total | |
| for fa in fatty_acids: | |
| fatty_acids[fa] += pct * getattr(oil, fa) | |
| return { | |
| "oils": {k: {"name": OILS[k].name, "weight_g": v, "pct": round(v/total*100, 1)} | |
| for k, v in recipe.items() if k in OILS}, | |
| "fatty_acids": {k: round(v, 1) for k, v in fatty_acids.items()}, | |
| "non_vegan_oils": non_vegan, | |
| "is_vegan": len(non_vegan) == 0, | |
| "total_weight_g": round(total, 1), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Cure timeline estimator | |
| # --------------------------------------------------------------------------- | |
| def cure_timeline(recipe: dict[str, float]) -> dict: | |
| """ | |
| Estimates cure time based on oil composition. | |
| Olive-heavy recipes need longer cures; coconut-heavy recipes cure faster. | |
| """ | |
| scores = score_recipe(recipe) | |
| if not scores: | |
| return {} | |
| iodine = scores["iodine_value"]["value"] | |
| hardness = scores["hardness"]["value"] | |
| total = sum(recipe.values()) | |
| if total <= 0: | |
| return {} | |
| olive_weight = sum(weight for oil, weight in recipe.items() if oil.startswith("olive oil")) | |
| coconut_weight = recipe.get("coconut oil", 0) | |
| castor_weight = recipe.get("castor oil", 0) | |
| olive_pct = olive_weight / total * 100 | |
| coconut_pct = coconut_weight / total * 100 | |
| castor_pct = castor_weight / total * 100 | |
| if olive_pct > 50: | |
| cure_min, cure_max = 6, 8 | |
| reason = "olive-heavy recipe — high-oleic bars benefit from a longer cure" | |
| elif coconut_pct > 40: | |
| cure_min, cure_max = 4, 4 | |
| reason = "coconut-heavy recipe — high-lauric bars cure relatively quickly" | |
| else: | |
| cure_min, cure_max = 4, 6 | |
| reason = "balanced recipe — a standard cure range should develop firmness and lather" | |
| if castor_pct > 20: | |
| cure_min += 1 | |
| cure_max += 1 | |
| reason += "; high castor content adds about 1 week" | |
| recommended_cure_label = ( | |
| f"{cure_min} weeks" if cure_min == cure_max else f"{cure_min}–{cure_max} weeks" | |
| ) | |
| weeks = { | |
| 1: "Soap is still actively saponifying. Do not use — may still be caustic.", | |
| 2: "Should be safe to unmold. Bar will feel soft and draggy. Let it breathe.", | |
| 3: "Water content dropping. Lather improving. Hardening noticeably.", | |
| 4: "Usable but not at its best. Lather and longevity still developing.", | |
| 5: "Good quality bar at this point for most recipes.", | |
| 6: "Lather is rich and stable. Bar is firm and long-lasting.", | |
| 7: "Premium quality. High-oleic bars really shine at this stage.", | |
| 8: "Fully cured. Best possible performance. Worth the wait.", | |
| 9: "Extended cure complete. High-castor or high-oleic bars should be at their best.", | |
| } | |
| timeline = {w: weeks[w] for w in range(1, cure_max + 1)} | |
| return { | |
| "recommended_cure_weeks": cure_max, | |
| "recommended_cure_min_weeks": cure_min, | |
| "recommended_cure_max_weeks": cure_max, | |
| "recommended_cure_label": recommended_cure_label, | |
| "reason": reason, | |
| "iodine_value": iodine, | |
| "hardness_score": hardness, | |
| "olive_pct": round(olive_pct, 1), | |
| "coconut_pct": round(coconut_pct, 1), | |
| "castor_pct": round(castor_pct, 1), | |
| "weekly_notes": timeline, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Lookup helpers | |
| # --------------------------------------------------------------------------- | |
| def list_oils(vegan_only: bool = False) -> list[str]: | |
| """Return sorted list of available oil keys.""" | |
| return sorted([k for k, v in OILS.items() if not vegan_only or v.vegan]) | |
| def get_oil(name: str) -> Optional[Oil]: | |
| """Fuzzy-ish lookup — tries exact key, then partial match.""" | |
| name_lower = name.lower().strip() | |
| if name_lower in OILS: | |
| return OILS[name_lower] | |
| # Partial match | |
| matches = [k for k in OILS if name_lower in k] | |
| if len(matches) == 1: | |
| return OILS[matches[0]] | |
| return None | |