""" 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 # --------------------------------------------------------------------------- @dataclass 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 @property def naoh_sap(self) -> float: """Average SAP value used in calculations.""" return round((self.naoh_sap_low + self.naoh_sap_high) / 2, 4) @property def koh_sap(self) -> float: """KOH SAP value (NaOH * 1.403).""" return round(self.naoh_sap * 1.403, 4) @property 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 ) @property 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