soap-lab / chemistry.py
293droid
fix parsing of prompts
a396ba1
Raw
History Blame
24 kB
"""
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