Thiết kế một phương pháp giảng dạy toán học mới mẻ và lôi cuốn nhằm nâng cao khả năng thấu hiểu và ghi nhớ kiến thức.
Hãy đóng vai một nhà giáo dục toán học sáng tạo. Bạn được giao nhiệm vụ phát triển một phương pháp giảng dạy độc đáo cho môn toán. Phương pháp của bạn cần: - Kết hợp các yếu tố tương tác để thu hút học sinh tham gia. - Sử dụng các ví dụ thực tế đời sống để minh họa những khái niệm phức tạp. - Tập trung vào kỹ năng giải quyết vấn đề và tư duy phản biện. - Thích ứng linh hoạt với các phong cách và tốc độ học tập khác nhau. Ví dụ: - Tạo một trò chơi toán học liên quan đến việc giải các câu đố về biểu thức đại số. - Xây dựng phương pháp kể chuyện để giải thích các khái niệm hình học. Mục tiêu của bạn là biến môn toán trở nên thú vị và dễ tiếp cận đối với mọi học sinh.
Chuyển đổi bất kỳ công thức bánh mì nào sang tỷ lệ phần trăm thợ làm bánh (baker's percentages) thực tế và điều chỉnh định lượng theo số ổ bánh hoặc viên bột bạn muốn. Tính toán cả lượng bột và nước có trong men cái (levain) cũng như lượng nước trong sữa, trứng hoặc mật ong, sau đó báo cáo tỷ lệ ngậm nước (hydration) thực tế, muối, bột lên men trước (prefermented flour), men và ngũ cốc nguyên cám. Cảnh báo khi thiếu hoặc sai lệch lượng muối, thiếu chất gây nở, quá nhiều men hoặc levain, và tỷ lệ ngậm nước nằm ngoài ngưỡng chuẩn của kiểu bánh đó. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: bread-dough-bakers-percentage-calculator
description: Chuyển đổi bất kỳ công thức bánh mì nào sang tỷ lệ phần trăm thợ làm bánh (baker's percentages) thực tế và điều chỉnh định lượng theo số ổ bánh hoặc viên bột bạn muốn. Tính toán cả lượng bột và nước có trong men cái (levain) cũng như lượng nước trong sữa, trứng hoặc mật ong, sau đó báo cáo tỷ lệ ngậm nước (hydration) thực tế, muối, bột lên men trước (prefermented flour), men và ngũ cốc nguyên cám. Cảnh báo khi thiếu hoặc sai lệch lượng muối, thiếu chất gây nở, quá nhiều men hoặc levain, và tỷ lệ ngậm nước nằm ngoài ngưỡng chuẩn của kiểu bánh đó. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Máy tính Tỷ lệ Phần trăm Thợ làm bánh cho Bột Bánh mì
Bạn giúp những người làm bánh tại nhà hiểu rõ, tinh chỉnh và nhân chia định lượng các công thức làm bánh mì. Từ một công thức tính bằng gam (hoặc đã được quy đổi sang gam), bạn tính toán tỷ lệ phần trăm thợ làm bánh thực tế, so sánh chúng với thông số tiêu chuẩn mà kiểu bánh mì đó thường cần, giải thích tác động của từng con số lên khối bột, và đưa ra danh sách nguyên liệu đã được điều chỉnh tỷ lệ chính xác theo số lượng cũng như trọng lượng ổ bánh mì, bánh mì cuộn (rolls) hoặc viên bột pizza mà họ muốn nướng.
## Các tệp trong kỹ năng này
- `scripts/dough_calc.py` - đọc một tệp JSON công thức và in ra tổng trọng lượng, bảng tỷ lệ phần trăm thợ làm bánh, công thức đã điều chỉnh tỷ lệ cùng các phát hiện đánh giá (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/bakers-math.md` - tỷ lệ phần trăm thợ làm bánh, tỷ lệ phần trăm thực tế so với tỷ lệ phần trăm trên công thức, lượng nước trong levain và các nguyên liệu phụ, các khoảng giá trị quan trọng, quy đổi các loại men, tỷ lệ ngậm nước theo từng kiểu bánh và cách nhân chia tỷ lệ
- `references/fermentation-and-troubleshooting.md` - mốc thời gian, nhiệt độ khối bột, ý nghĩa của từng phát hiện đánh giá đối với khối bột, các triệu chứng lỗi và cách khắc phục, độ chín của bánh
- `templates/bake-sheet.md` - phiếu ghi chép mẻ nướng gồm công thức, lịch trình thời gian và ghi chú cho lần nướng tiếp theo
- `examples/weekend-whole-wheat-sourdough.json` - công thức bánh mì sourdough lúa mì nguyên cám và lúa mạch đen dùng men cái (levain), một chút men nở ăn liền (instant yeast), mật ong và dầu ô liu, được điều chỉnh định lượng thành hai ổ bánh nặng 1 kg
## Quy trình làm việc
### 1. Lấy công thức tính theo gam
Hãy hỏi trọng lượng của từng nguyên liệu. Quy đổi đơn vị cốc (cups) và thìa (spoons) thật cẩn thận (1 cup bột bánh mì khoảng 125-135 g, 1 cup nước là 236 g, 1 tsp muối mịn khoảng 6 g, 1 tsp men instant khoảng 3 g) và nói rõ rằng phép quy đổi này chỉ mang tính tương đối. Hỏi thêm về tỷ lệ ngậm nước của levain, những loại bột nào là bột nguyên cám, kiểu bánh mì muốn làm, loại men sử dụng, cũng như số lượng và trọng lượng từng phần bột mà họ mong muốn.
### 2. Chạy công cụ tính toán
```
python3 scripts/dough_calc.py examples/weekend-whole-wheat-sourdough.json
python3 scripts/dough_calc.py recipe.json --pieces 6 --piece-weight 270 --json
python3 scripts/dough_calc.py --self-test
```
Các kiểu bánh (`styles`): bagel, pizza_neapolitan, sandwich_loaf, enriched_brioche, baguette, sourdough_boule, whole_wheat_loaf, ciabatta, focaccia. Mã thoát (exit code) bằng 1 nghĩa là có các phát hiện mức HIGH, bằng 2 nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Giải thích và điều chỉnh
Đưa ra các con số quan trọng trước tiên (tỷ lệ ngậm nước, muối, chất gây nở), sau đó nêu từng phát hiện đánh giá kèm theo lý do và mức thay đổi cụ thể bằng gam dựa theo `references/bakers-math.md`. Chỉ đề xuất thay đổi một hoặc hai yếu tố cho mỗi mẻ nướng để người làm bánh có thể quan sát rõ tác động của từng thay đổi. Sử dụng `references/fermentation-and-troubleshooting.md` để tư vấn về thời gian ủ và xử lý các vấn đề họ mô tả (bánh bị bẹt, ruột bánh đặc, bánh quá chua).
### 4. Bàn giao kết quả
Cung cấp công thức đã điều chỉnh tỷ lệ và điền vào mẫu `templates/bake-sheet.md` với công thức hoàn chỉnh, lịch trình thời gian phù hợp với nhiệt độ phòng bếp của họ cùng phần trống để ghi chú.
## Ví dụ kết quả
Đối với tệp `examples/weekend-whole-wheat-sourdough.json`: tổng lượng bột là 1100 g (trong đó có 100 g nằm trong levain) và tổng lượng nước là 785 g, do đó tỷ lệ ngậm nước thực tế là 71.4%, muối 1.4%, bột lên men trước 9.1%, men instant 0.27% và ngũ cốc nguyên cám 38.2%. Lượng muối đang thấp (mức MEDIUM; cần 22 g để đạt 2%) và mức 38% ngũ cốc nguyên cám sẽ cần thêm khoảng 5 điểm phần trăm nước nữa (mức LOW). Khi nhân tỷ lệ lên 2 ổ x 1000 g cộng thêm 2% hao hụt (hệ số nhân 1.047): 628 g bột bánh mì (bread flour), 314 g bột nguyên cám (whole wheat), 105 g bột lúa mạch đen (rye), 712 g nước, 15.7 g muối (nên tăng lên khoảng 23 g), 209 g levain, 3.1 g men, 31 g mật ong, 21 g dầu ô liu.
## Quy tắc
- Luôn làm việc bằng đơn vị gam; nói rõ khi nào phép quy đổi từ thể tích sang trọng lượng chỉ là xấp xỉ.
- Luôn nêu rõ tỷ lệ phần trăm đang nói tới là tỷ lệ thực tế (đã tính cả levain và nguyên liệu phụ) hay tỷ lệ phần trăm bề mặt theo công thức.
- Chỉ thay đổi một hoặc hai yếu tố mỗi lần và giải thích rõ tác động của chúng lên hương vị, kết cấu và thời gian ủ.
- Thời gian lên men phụ thuộc vào nhiệt độ và độ khỏe của men cái; hãy đưa ra khoảng thời gian ước lượng kèm các dấu hiệu nhận biết trực quan cần quan sát, thay vì ấn định số phút cứng nhắc.
FILE:references/bakers-math.md
# Toán học cho Thợ làm bánh (Baker's Math)
## Tỷ lệ phần trăm thợ làm bánh (Baker's percentages)
Mọi nguyên liệu đều được biểu diễn dưới dạng tỷ lệ phần trăm so với **tổng trọng lượng bột**, và tổng lượng bột luôn được quy ước là 100%. Một công thức gồm 1000 g bột, 700 g nước, 20 g muối và 5 g men instant sẽ có tỷ lệ là 100 / 70 / 2 / 0.5. Tỷ lệ phần trăm giúp các công thức dễ dàng được so sánh, nhân chia định lượng và điều chỉnh: chỉ cần thay đổi một con số và giữ nguyên phần còn lại.
## Tỷ lệ phần trăm thực tế so với tỷ lệ phần trăm trên công thức
Các công thức thường chỉ đưa ra tỷ lệ phần trăm tính theo lượng bột được thêm vào ở bước trộn cuối cùng. Tỷ lệ phần trăm thực tế (tổng thể) sẽ tính cả:
- lượng bột và nước nằm bên trong men cái (levain) hoặc bột cái ủ trước (preferment)
- lượng nước có sẵn bên trong các nguyên liệu phụ dạng ướt
`scripts/dough_calc.py` luôn báo cáo tỷ lệ phần trăm thực tế, vì vậy các hàng biểu thị lượng bột trộn thêm trong bảng kết quả sẽ có tổng nhỏ hơn 100% khi công thức có sử dụng levain (phần bột còn lại nằm ở trong levain).
| Tỷ lệ ngậm nước của Levain | Lượng bột trong 100 g levain | Lượng nước trong 100 g levain |
|---|---|---|
| 50% (đặc / stiff) | 66.7 g | 33.3 g |
| 75% | 57.1 g | 42.9 g |
| 100% (lỏng / liquid) | 50 g | 50 g |
| 125% | 44.4 g | 55.6 g |
Công thức tính: bột = levain / (1 + hydration / 100); nước = levain - bột.
## Hàm lượng nước trong các nguyên liệu phụ phổ biến
| Nguyên liệu | Hàm lượng nước |
|---|---|
| Sữa nguyên kem | 87% |
| Trứng nguyên quả (bỏ vỏ, ~50 g) | 75% |
| Lòng đỏ trứng | 50% |
| Bơ | 16% |
| Mật ong | 17% |
| Sữa chua | 85% |
| Khoai tây nghiền | 80% |
| Dầu ô liu, đường, các loại hạt | 0% (chất béo và đường vẫn làm mềm khối bột) |
## Các thông số quan trọng
| Chỉ số | Mức tiêu chuẩn | Ghi chú |
|---|---|---|
| Muối | 1.8-2.2% | dưới 1.6% bánh nhạt và bột nhão; trên 2.5% bánh mặn, làm chậm quá trình lên men |
| Men instant (trộn bột trực tiếp / straight dough) | 0.5-1.5% | 0.1-0.3% khi ủ lạnh qua đêm |
| Levain, tính theo tỷ lệ bột lên men trước | 10-25% | nhiều hơn = lên men nhanh hơn và chua hơn |
| Đường (bột giàu dinh dưỡng / enriched) | 5-15% | trên ~10% nên dùng men chịu áp suất thẩm thấu cao (osmotolerant yeast) hoặc tăng lượng men |
| Chất béo (bột giàu dinh dưỡng / enriched) | 3-50% | bánh brioche dùng 40-60% bơ |
Quy đổi các loại men (cùng sức nở tương đương): 1 g men instant = 1.25 g men khô kích hoạt (active dry) = khoảng 3 g men tươi (fresh yeast). Tập lệnh quy đổi toàn bộ các loại men về mức tương đương của men instant.
## Tỷ lệ ngậm nước theo kiểu bánh
| Kiểu bánh | Tỷ lệ ngậm nước (Hydration %) | Kết cấu |
|---|---|---|
| Bagel | 50-58 | đặc, dai (chewy) |
| Neapolitan pizza | 58-65 | mềm, viền bánh phồng xốp |
| Sandwich loaf | 60-68 | thớ bánh đều, khít |
| Enriched / brioche (gồm sữa và trứng) | 55-70 | mềm mại, béo ngậy |
| Baguette | 65-75 | ruột thoáng lỗ khí, vỏ giòn rụm |
| Sourdough boule | 68-80 | ruột thoáng (open crumb) |
| Whole wheat loaf | 70-85 | bột nguyên cám hút nhiều nước hơn |
| Ciabatta | 75-85 | ruột rất thoáng, nhiều lỗ khí lớn |
| Focaccia | 75-90 | xốp thoáng, đẫm dầu |
Loại bột rất quan trọng: bột bánh mì giàu protein và bột nguyên cám hút nhiều nước hơn; bột mì đa dụng ít protein hoặc nhiều loại bột châu Âu T55/Type 550 hút ít nước hơn. Hãy bắt đầu ở mức thấp hơn 3-5 điểm phần trăm khi dùng một loại bột mới và châm thêm nước từ từ trong quá trình nhào trộn (kỹ thuật bassinage).
## Nhân chia định lượng (Scaling)
1. Quyết định số lượng phần bột và trọng lượng mỗi phần (ổ bánh, bánh cuộn, viên bột pizza).
2. Cộng thêm 1-3% bù cho lượng bột hao hụt dính ở âu trộn và trên mặt bàn nhào.
3. Hệ số nhân (Factor) = trọng lượng bột mục tiêu / trọng lượng bột hiện tại; nhân mọi nguyên liệu với hệ số này.
4. Làm tròn số gam hợp lý: làm tròn đến 1 g đối với bột và nước, 0.1 g đối với muối và men (hãy dùng cân tiểu ly độ chia 0.1 g cho các định lượng nhỏ).
Bánh mì nướng khuôn (Pan loaves): trọng lượng khối bột rơi vào khoảng 0.55-0.6 g trên mỗi cm3 thể tích khuôn (một khuôn loại 900 g / 2 lb sẽ cần khoảng 850-950 g bột).
FILE:references/fermentation-and-troubleshooting.md
# Lên men và Xử lý sự cố
## Lịch trình tiêu chuẩn cho bột bánh mì cơ bản (lean dough)
| Bước | Dùng men công nghiệp (1% instant) | Dùng Sourdough (15-20% levain) |
|---|---|---|
| Trộn và nghỉ | 20-30 phút autolyse (tùy chọn) | 30-60 phút autolyse (tùy chọn) |
| Ủ lần 1 (Bulk fermentation) ở 24-26 C | 1.5-2 giờ, nở ~75-100% | 4-6 giờ, nở ~50-75% |
| Gấp bột (Folds) | 1-2 lần | 3-4 lần trong 2 giờ đầu |
| Tạo hình, ủ kết thúc (final proof) | 45-75 phút | 1-3 giờ, hoặc ủ lạnh 8-16 giờ trong tủ lạnh |
| Nướng (Bake) | 220-230 C | 240-250 C đậy nắp 20 phút, sau đó 220-230 C mở nắp |
Hãy hướng tới nhiệt độ khối bột đạt 24-26 C sau khi trộn. Điều chỉnh bằng nhiệt độ nước: nhiệt độ nước mong muốn = 3 x nhiệt độ bột mong muốn - nhiệt độ bột khô - nhiệt độ phòng - độ tăng nhiệt do ma sát (khoảng 2-6 C khi nhào tay, cao hơn nếu nhào bằng máy). Khi có dùng levain, hãy nhân với hệ số 4 và trừ đi cả nhiệt độ của levain.
## Quy tắc ngón tay cái về nhiệt độ
Thời gian lên men sẽ giảm khoảng một nửa khi nhiệt độ tăng thêm +8 C và tăng gấp đôi khi nhiệt độ giảm đi -8 C. Quá trình ủ lần 1 (bulk) của sourdough mất 5 giờ ở 24 C sẽ cần khoảng 8 giờ nếu ủ ở 20 C.
## Cách đọc các mã đánh giá từ tập lệnh
- **SALT_LOW / SALT_HIGH:** hãy chỉnh lại lượng muối trước tiên; muối làm thay đổi hương vị, độ khỏe của mạng lưới gluten và tốc độ lên men.
- **HYDRATION_HIGH:** khối bột sẽ dính và dễ bị chảy bè ra; hãy làm ướt tay khi thao tác, gấp bột nhiều lần hơn, hoặc giữ lại 5% lượng nước để châm sau.
- **HYDRATION_LOW:** ruột bánh khít đặc và khối bột khi trộn khá khô cứng; hãy châm thêm nước từng chút một.
- **YEAST_HIGH:** rút ngắn thời gian ủ lần 1, hoặc giảm bớt lượng men để khớp với lịch trình của bạn (dùng 0.2-0.5% cho quá trình ủ lạnh kéo dài).
- **LEVAIN_HIGH:** quá trình lên men diễn ra nhanh hơn, mang hương vị nồng ấm hơn nhưng dễ bị ủ quá lửa (overproof); hãy giảm lượng levain xuống mức 10-20% hoặc ủ ở nhiệt độ mát hơn.
- **WHOLE_GRAIN_THIRSTY:** thêm 5-10 điểm phần trăm nước hoặc ngâm phần bột nguyên cám trước từ 30-60 phút.
## Triệu chứng lỗi và cách khắc phục
| Vấn đề | Nguyên nhân có thể | Cách khắc phục |
|---|---|---|
| Ổ bánh bị bẹt, chảy bè ngang | ủ quá lửa (overproofed), quá nhão so với sức hút của bột, tạo hình lỏng tay | rút ngắn thời gian ủ cuối, giảm 3-5 điểm phần trăm nước, tạo hình căng mặt bột hơn, dùng rổ ủ bột (banneton) |
| Ruột bánh đặc, khít | chưa ủ tới (underproofed), quá ít nước, men cái yếu | kéo dài thời gian ủ lần 1, giữ bột ấm hơn, cho men cái ăn tới khi đạt độ nở gấp đôi trong 4-6 giờ |
| Lỗ rỗng lớn ngay dưới lớp vỏ (vỏ tách rời / flying crust) | chưa ủ tới hoặc tạo hình kém | kéo dài thời gian ủ cuối, ép khí nhẹ nhàng trong lúc tạo hình |
| Vỏ bánh nhợt nhạt | lò chưa đủ nóng, bột ủ quá lửa, thiếu hơi nước | làm nóng lò trước 45-60 phút, tạo hơi nước trong 15-20 phút đầu tiên |
| Ruột bánh bị bết dính (gummy) | cắt bánh quá sớm, nướng chưa chín tới | nướng tới khi tâm bánh đạt 96-98 C, để nguội 1-2 giờ trước khi thái lát |
| Bánh quá chua | ủ ấm quá lâu, levain đã qua đỉnh nở (past peak) | dùng levain trẻ hơn, giảm lượng levain, rút ngắn thời gian hoặc ủ lần 1 ở nhiệt độ mát hơn |
| Vị bánh nhạt nhẽo | lượng muối dưới 1.8%, lên men quá nhanh | cân muối chính xác, giảm lượng men và ủ lâu hơn ở nhiệt độ mát hơn |
## Độ chín và an toàn thực phẩm
- Nướng bánh mì cơ bản (lean bread) tới khi nhiệt độ tâm bánh đạt 93-98 C và bánh mì giàu dinh dưỡng (enriched bread) đạt 85-90 C (dùng nhiệt kế đọc kết quả tức thì).
- Bột sống có thể mang vi khuẩn: tuyệt đối không nếm thử bột sống.
- Hũ men cái xuất hiện vệt màu hồng, cam hoặc nấm mốc có lông tơ là không an toàn; hãy vứt bỏ ngay.
FILE:templates/bake-sheet.md
# Phiếu Nướng Bánh (Bake Sheet) - <tên công thức> - <ngày>
**Kiểu bánh:** <sourdough boule> **Số lượng:** <2 x 1000 g> **Nhiệt độ bếp:** <22 C>
## Công thức (tỷ lệ phần trăm thợ làm bánh thực tế)
| Nguyên liệu | % | Số gam (mẻ nướng này) |
|---|---|---|
| Tổng lượng bột | 100 | |
| Tổng lượng nước | | |
| Muối | | |
| Levain / men | | |
| Nguyên liệu phụ | | |
Tỷ lệ ngậm nước <..>% | muối <..>% | bột lên men trước <..>% | ngũ cốc nguyên cám <..>%
## Thay đổi so với lần trước
- <ví dụ: +15 g muối để đạt mức 2%, +50 g nước cho phần bột nguyên cám>
## Lịch trình thời gian
| Bước | Dự kiến | Thực tế | Ghi chú (nhiệt độ bột, độ nở %) |
|---|---|---|---|
| Cho levain ăn | | | |
| Trộn bột | | | |
| Kết thúc ủ lần 1 (Bulk) | | | |
| Tạo hình | | | |
| Kết thúc ủ cuối (Proof) | | | |
| Nướng bánh | | | |
## Kết quả
- Ruột bánh (Crumb): <thoáng / khít / bết dính> - Vỏ bánh (Crust): <màu sắc, độ dày> - Hương vị: <độ mặn, độ chua>
- Lần tới: <chỉ thay đổi một yếu tố duy nhất>
FILE:examples/weekend-whole-wheat-sourdough.json
{
"name": "Weekend whole wheat sourdough",
"style": "sourdough_boule",
"flours": [
{"name": "bread flour (12.5% protein)", "grams": 600},
{"name": "whole wheat flour", "grams": 300, "whole_grain": true},
{"name": "whole rye flour", "grams": 100, "whole_grain": true}
],
"water_g": 680,
"salt_g": 15,
"levain": {"grams": 200, "hydration_pct": 100, "whole_grain_pct": 20},
"yeast": {"type": "instant", "grams": 3},
"extras": [
{"name": "honey", "grams": 30, "water_pct": 17},
{"name": "olive oil", "grams": 20, "water_pct": 0}
],
"target": {"pieces": 2, "piece_weight_g": 1000},
"loss_pct": 2
}
FILE:scripts/dough_calc.py
#!/usr/bin/env python3
"""Bread dough baker's percentage calculator (Python 3 standard library only).
Input JSON:
{"name": "...", "style": "sourdough_boule",
"flours": [{"name": "bread flour", "grams": 800}, {"name": "whole wheat", "grams": 200, "whole_grain": true}],
"water_g": 700, "salt_g": 20,
"levain": {"grams": 200, "hydration_pct": 100, "whole_grain_pct": 0}, (optional)
"yeast": {"type": "instant|active_dry|fresh", "grams": 0}, (optional)
"extras": [{"name": "milk", "grams": 100, "water_pct": 87}], (optional)
"target": {"pieces": 3, "piece_weight_g": 900}, "loss_pct": 2} (optional)
Totals count the flour and water inside the levain and the water inside extras (milk ~87%,
whole egg ~75%, butter ~16%), so hydration and salt are the TRUE percentages of all flour.
hydration % = all water / all flour x 100
salt % = salt / all flour x 100
prefermented flour % = flour in the levain / all flour x 100
yeast = instant-yeast equivalent (active dry x 0.8, fresh x 0.33) / all flour x 100
Scaling: factor = pieces x piece weight x (1 + loss %) / current dough weight; grams rounded to 1 g,
salt and yeast to 0.1 g.
Findings (severity):
HIGH NO_SALT no salt (bland, slack, fast dough) - unless salt_free is true
HIGH SALT_HIGH salt above 2.5% of flour
HIGH NO_LEAVEN no yeast and no levain (dough will not rise)
HIGH HYDRATION_FAR hydration more than 10 points outside the style range
MEDIUM SALT_LOW salt below 1.6%
MEDIUM HYDRATION_HIGH/LOW hydration outside the style range
MEDIUM YEAST_HIGH instant-equivalent yeast above 2% (or above 1% together with a levain)
MEDIUM LEVAIN_HIGH prefermented flour above 35% (overproofs, sour, slack)
LOW LEVAIN_LOW levain-only dough with under 5% prefermented flour (very slow)
LOW WHOLE_GRAIN_THIRSTY 30%+ whole grain with hydration under 72%
LOW UNKNOWN_STYLE style not in the table; no hydration check
Usage:
python3 dough_calc.py recipe.json
python3 dough_calc.py recipe.json --pieces 4 --piece-weight 250 --json
python3 dough_calc.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import sys
STYLES = { # typical hydration range in % (true, incl. levain and extras water)
"bagel": (50, 58),
"pizza_neapolitan": (58, 65),
"sandwich_loaf": (60, 68),
"enriched_brioche": (55, 70),
"baguette": (65, 75),
"sourdough_boule": (68, 80),
"whole_wheat_loaf": (70, 85),
"ciabatta": (75, 85),
"focaccia": (75, 90),
}
YEAST_FACTOR = {"instant": 1.0, "active_dry": 0.8, "fresh": 0.33}
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
class DataError(ValueError):
pass
def g(x, what):
try:
v = float(x)
except (TypeError, ValueError):
raise DataError(f"{what} must be a number, got {x!r}")
if v < 0:
raise DataError(f"{what} must be >= 0")
return v
def analyze(rec, pieces=None, piece_weight=None):
flours = rec.get("flours") or []
if not flours:
raise DataError("at least one flour is required")
lines = [] # (name, grams, kind)
added_flour = whole = 0.0
for i, fl in enumerate(flours):
w = g(fl.get("grams"), f"flours[{i}].grams")
added_flour += w
if fl.get("whole_grain"):
whole += w
lines.append((fl.get("name") or f"flour {i + 1}", w, "flour"))
water = g(rec.get("water_g", 0), "water_g")
salt = g(rec.get("salt_g", 0), "salt_g")
lines += [("water", water, "water"), ("salt", salt, "salt")]
lev = rec.get("levain") or {}
lev_g = g(lev.get("grams", 0), "levain.grams")
lev_flour = lev_water = 0.0
if lev_g:
h = g(lev.get("hydration_pct", 100), "levain.hydration_pct")
if h <= 0:
raise DataError("levain.hydration_pct must be > 0")
lev_flour = lev_g / (1 + h / 100)
lev_water = lev_g - lev_flour
whole += lev_flour * g(lev.get("whole_grain_pct", 0), "levain.whole_grain_pct") / 100
lines.append((f"levain ({h:g}% hydration)", lev_g, "levain"))
y = rec.get("yeast") or {}
y_g = g(y.get("grams", 0), "yeast.grams")
y_type = y.get("type", "instant")
if y_type not in YEAST_FACTOR:
raise DataError(f"yeast.type must be one of {', '.join(YEAST_FACTOR)}")
if y_g:
lines.append((f"{y_type.replace('_', ' ')} yeast", y_g, "yeast"))
extra_water = 0.0
for i, ex in enumerate(rec.get("extras") or []):
w = g(ex.get("grams"), f"extras[{i}].grams")
extra_water += w * g(ex.get("water_pct", 0), f"extras[{i}].water_pct") / 100
lines.append((ex.get("name") or f"extra {i + 1}", w, "extra"))
flour = added_flour + lev_flour
if flour <= 0:
raise DataError("total flour must be > 0")
total_water = water + lev_water + extra_water
dough = sum(w for _, w, _ in lines)
pct = lambda x: round(100 * x / flour, 1)
res = {
"name": rec.get("name", "recipe"), "style": rec.get("style"),
"total_flour_g": round(flour, 1), "total_water_g": round(total_water, 1), "dough_weight_g": round(dough, 1),
"hydration_pct": pct(total_water), "salt_pct": pct(salt), "prefermented_flour_pct": pct(lev_flour),
"yeast_instant_pct": round(100 * y_g * YEAST_FACTOR[y_type] / flour, 2), "whole_grain_pct": pct(whole),
"table": [{"ingredient": n, "grams": round(w, 1), "bakers_pct": pct(w)} for n, w, _ in lines],
}
findings = []
def f(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
hyd, sp, pf, yp = res["hydration_pct"], res["salt_pct"], res["prefermented_flour_pct"], res["yeast_instant_pct"]
if salt == 0 and not rec.get("salt_free"):
f("HIGH", "NO_SALT", f"no salt: add about 2% of the flour weight ({0.02 * flour:.0f} g)")
elif sp > 2.5:
f("HIGH", "SALT_HIGH", f"salt {sp}% (usual 1.8-2.2%, {0.02 * flour:.0f} g at 2%); slows yeast and tastes salty")
elif 0 < sp < 1.6:
f("MEDIUM", "SALT_LOW", f"salt {sp}% (usual 1.8-2.2%, {0.02 * flour:.0f} g at 2%); bland and slack dough")
if y_g == 0 and lev_g == 0:
f("HIGH", "NO_LEAVEN", "no yeast and no levain: the dough will not rise")
if yp > 2 or (lev_g and yp > 1):
f("MEDIUM", "YEAST_HIGH", f"instant-yeast equivalent {yp}%{' on top of a levain' if lev_g else ''}; expect a fast, yeasty rise")
if pf > 35:
f("MEDIUM", "LEVAIN_HIGH", f"prefermented flour {pf}% (usual 10-25%); faster, more sour and slacker")
elif lev_g and y_g == 0 and pf < 5:
f("LOW", "LEVAIN_LOW", f"prefermented flour {pf}%: bulk fermentation will be very long")
style = rec.get("style")
if style in STYLES:
lo, hi = STYLES[style]
if hyd < lo or hyd > hi:
gap = lo - hyd if hyd < lo else hyd - hi
sev = "HIGH" if gap > 10 else "MEDIUM"
code = "HYDRATION_FAR" if gap > 10 else ("HYDRATION_LOW" if hyd < lo else "HYDRATION_HIGH")
f(sev, code, f"hydration {hyd}% vs {lo}-{hi}% typical for {style}; target water {round(flour * (lo + hi) / 200 - (total_water - water))} g")
elif style:
f("LOW", "UNKNOWN_STYLE", f"style {style!r} not known ({', '.join(STYLES)}); no hydration check")
if res["whole_grain_pct"] >= 30 and hyd < 72:
f("LOW", "WHOLE_GRAIN_THIRSTY", f"{res['whole_grain_pct']}% whole grain at {hyd}% hydration; whole grain absorbs more, consider +5 points")
findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
res["findings"] = findings
tgt = rec.get("target") or {}
pieces = pieces or tgt.get("pieces")
piece_weight = piece_weight or tgt.get("piece_weight_g")
if pieces and piece_weight:
loss = g(rec.get("loss_pct", 2), "loss_pct")
want = g(pieces, "pieces") * g(piece_weight, "piece_weight_g") * (1 + loss / 100)
k = want / dough
res["scaled"] = {"pieces": int(pieces), "piece_weight_g": float(piece_weight), "loss_pct": loss,
"factor": round(k, 3), "dough_weight_g": round(want),
"ingredients": [{"ingredient": n, "grams": round(w * k, 1) if kind in ("salt", "yeast") else round(w * k)}
for n, w, kind in lines]}
return res
def report(r):
L = [f"# {r['name']}" + (f" ({r['style']})" if r.get("style") else ""), "",
f"Total flour {r['total_flour_g']:g} g, total water {r['total_water_g']:g} g, dough {r['dough_weight_g']:g} g",
f"Hydration {r['hydration_pct']}% | salt {r['salt_pct']}% | prefermented flour {r['prefermented_flour_pct']}% | "
f"yeast (instant eq.) {r['yeast_instant_pct']}% | whole grain {r['whole_grain_pct']}%", "",
"| Ingredient | Grams | Baker's % |", "|---|---|---|"]
L += [f"| {t['ingredient']} | {t['grams']:g} | {t['bakers_pct']} |" for t in r["table"]]
if "scaled" in r:
s = r["scaled"]
L += ["", f"## Scaled to {s['pieces']} x {s['piece_weight_g']:g} g (+{s['loss_pct']:g}% loss, factor {s['factor']}): {s['dough_weight_g']} g dough",
"| Ingredient | Grams |", "|---|---|"]
L += [f"| {t['ingredient']} | {t['grams']:g} |" for t in s["ingredients"]]
L += ["", "## Findings"]
L += [f"- {x['severity']} {x['code']}: {x['message']}" for x in r["findings"]] or ["- none"]
return "\n".join(L) + "\n"
def self_test():
cases = []
def ok(name, cond):
cases.append(bool(cond))
print(("PASS " if cond else "FAIL ") + name)
def codes(r):
return {x["code"] for x in r["findings"]}
base = {"style": "sourdough_boule", "flours": [{"grams": 1000}], "water_g": 700, "salt_g": 20,
"yeast": {"type": "instant", "grams": 5}}
r = analyze(base)
ok("hydration 70%, salt 2%", r["hydration_pct"] == 70 and r["salt_pct"] == 2)
ok("dough weight sums ingredients (1725)", r["dough_weight_g"] == 1725)
ok("clean recipe has no findings", r["findings"] == [])
lev = dict(base, flours=[{"grams": 900}], water_g=600, levain={"grams": 200, "hydration_pct": 100}, yeast=None)
r = analyze(lev)
ok("levain 100%: flour 1000, water 700", r["total_flour_g"] == 1000 and r["total_water_g"] == 700)
ok("prefermented flour 10%", r["prefermented_flour_pct"] == 10)
r = analyze(dict(lev, levain={"grams": 150, "hydration_pct": 50}))
ok("stiff levain 50%: 100 g flour + 50 g water", r["total_flour_g"] == 1000 and r["total_water_g"] == 650)
ok("yeast conversion fresh 15 g ~ 0.5% instant", analyze(dict(base, yeast={"type": "fresh", "grams": 15}))["yeast_instant_pct"] == 0.49)
ok("active dry x 0.8", analyze(dict(base, yeast={"type": "active_dry", "grams": 10}))["yeast_instant_pct"] == 0.8)
milk = analyze(dict(base, water_g=600, extras=[{"name": "milk", "grams": 100, "water_pct": 87}]))
ok("milk water counted (87 g)", milk["total_water_g"] == 687)
ok("no salt HIGH", "NO_SALT" in codes(analyze(dict(base, salt_g=0))))
ok("salt_free suppresses NO_SALT", "NO_SALT" not in codes(analyze(dict(base, salt_g=0, salt_free=True))))
ok("salt 3% HIGH", "SALT_HIGH" in codes(analyze(dict(base, salt_g=30))))
ok("salt 1.2% MEDIUM", "SALT_LOW" in codes(analyze(dict(base, salt_g=12))))
ok("no leaven HIGH", "NO_LEAVEN" in codes(analyze(dict(base, yeast=None))))
ok("yeast 2.5% MEDIUM", "YEAST_HIGH" in codes(analyze(dict(base, yeast={"type": "instant", "grams": 25}))))
ok("yeast 1.2% plus levain MEDIUM", "YEAST_HIGH" in codes(analyze(dict(lev, yeast={"type": "instant", "grams": 12}))))
ok("levain 40% flour MEDIUM", "LEVAIN_HIGH" in codes(analyze(dict(lev, flours=[{"grams": 600}], water_g=300, levain={"grams": 800, "hydration_pct": 100}))))
ok("tiny levain LOW", "LEVAIN_LOW" in codes(analyze(dict(lev, flours=[{"grams": 980}], water_g=680, levain={"grams": 40, "hydration_pct": 100}))))
ok("hydration 64% for boule MEDIUM LOW", "HYDRATION_LOW" in codes(analyze(dict(base, water_g=640))))
ok("hydration 84% for boule MEDIUM HIGH", "HYDRATION_HIGH" in codes(analyze(dict(base, water_g=840))))
ok("hydration 92% for bagel HIGH FAR", "HYDRATION_FAR" in codes(analyze(dict(base, style="bagel", water_g=920))))
ok("unknown style LOW", "UNKNOWN_STYLE" in codes(analyze(dict(base, style="pretzel"))))
wg = analyze(dict(base, flours=[{"grams": 600}, {"grams": 400, "whole_grain": True}], water_g=690))
ok("whole grain 40% at 69% flagged", wg["whole_grain_pct"] == 40 and "WHOLE_GRAIN_THIRSTY" in codes(wg))
s = analyze(base, pieces=2, piece_weight=900)["scaled"]
ok("scale 2 x 900 + 2% = 1836 g, factor 1.064", s["dough_weight_g"] == 1836 and s["factor"] == 1.064)
ok("scaled flour 1064 g, salt 21.3 g", s["ingredients"][0]["grams"] == 1064 and s["ingredients"][2]["grams"] == 21.3)
ok("target from JSON", analyze(dict(base, target={"pieces": 4, "piece_weight_g": 250}, loss_pct=0))["scaled"]["dough_weight_g"] == 1000)
for bad, label in (({"flours": []}, "no flours"), ({"flours": [{"grams": "x"}]}, "bad number"),
({"flours": [{"grams": -5}]}, "negative grams"), ({"flours": [{"grams": 0}]}, "zero flour"),
(dict(base, yeast={"type": "sourdough", "grams": 5}), "unknown yeast type"),
(dict(lev, levain={"grams": 100, "hydration_pct": 0}), "zero levain hydration")):
try:
analyze(bad)
ok(label + " rejected", False)
except DataError:
ok(label + " rejected", True)
print(f"{sum(cases)}/{len(cases)} passed")
return 0 if all(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Baker's percentages, true hydration and scaling for bread dough.")
ap.add_argument("file", nargs="?", help="recipe JSON (see module docstring)")
ap.add_argument("--pieces", type=int, help="number of loaves or dough balls (overrides target)")
ap.add_argument("--piece-weight", type=float, help="dough weight per piece in grams (overrides target)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("give a recipe JSON file or --self-test")
try:
r = analyze(json.load(open(a.file, encoding="utf-8")), a.pieces, a.piece_weight)
except (OSError, ValueError, DataError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else report(r), end="\n" if a.json else "")
return 1 if any(x["severity"] == "HIGH" for x in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Tôi là một chuyên gia nội dung (SME) về môn Toán, đặc biệt là nội dung chương trình Ấn Độ. Tôi đang có các tệp HTML do các lập trình viên/nhà thiết kế tạo ra. Tôi muốn đánh giá chúng dưới cả góc độ sư phạm lẫn góc độ thiết kế. Ngoài ra, giả sử nội dung nào đó dành cho lớp 8 thì ngay cả học sinh lớp 7 cũng có thể hiểu được, tệp HTML đó cần phải mượt mà và hấp dẫn đến mức như vậy.
Tôi là một chuyên gia nội dung (SME) về môn Toán, đặc biệt là nội dung chương trình Ấn Độ. Tôi đang có các tệp HTML do các lập trình viên/nhà thiết kế tạo ra. Tôi muốn đánh giá chúng dưới cả góc độ sư phạm lẫn góc độ thiết kế. Ngoài ra, giả sử nội dung nào đó dành cho lớp 8 thì ngay cả học sinh lớp 7 cũng có thể hiểu được, tệp HTML đó cần phải mượt mà và hấp dẫn đến mức như vậy.
Quy đổi công thức nấu ăn theo khẩu phần, hệ số nhân hoặc kích thước khuôn (vuông, chữ nhật, tròn, khuôn bánh mì gối), chuyển đổi giữa cốc (cup) và thìa chuẩn Mỹ, thể tích hệ mét và gam thông qua bảng khối lượng riêng của nguyên liệu, làm tròn về mức có thể đong đếm được, đồng thời cảnh báo các lưu ý khi làm bánh như trứng lẻ phần thập phân, chất tạo nở ở hệ số nhân lớn, độ dày bột và thời gian nướng khi thay đổi khuôn, định lượng quá nhỏ, gia vị và thời gian nấu. Bao gồm một trình quy đổi bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: recipe-scaling-and-unit-converter
description: Quy đổi công thức nấu ăn theo khẩu phần, hệ số nhân hoặc kích thước khuôn (vuông, chữ nhật, tròn, khuôn bánh mì gối), chuyển đổi giữa cốc (cup) và thìa chuẩn Mỹ, thể tích hệ mét và gam thông qua bảng khối lượng riêng của nguyên liệu, làm tròn về mức có thể đong đếm được, đồng thời cảnh báo các lưu ý khi làm bánh như trứng lẻ phần thập phân, chất tạo nở ở hệ số nhân lớn, độ dày bột và thời gian nướng khi thay đổi khuôn, định lượng quá nhỏ, gia vị và thời gian nấu. Bao gồm một trình quy đổi bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Công cụ Quy đổi Khẩu phần Công thức và Chuyển đổi Đơn vị
Bạn giúp những người nấu ăn tại gia và thợ làm bánh tăng, giảm khối lượng công thức hoặc điều chỉnh cho vừa với một chiếc khuôn khác, bằng chính những đơn vị mà họ thực sự sử dụng. Bạn đưa ra các định lượng mà họ có thể đong đếm được, hiển thị hệ số nhân và các phép chuyển đổi đằng sau đó, đồng thời cảnh báo về những yếu tố không tăng giảm theo tỷ lệ tuyến tính: trứng, chất tạo nở, độ sâu của khuôn, thời gian nướng, gia vị và thời gian nấu.
## Các tệp trong kỹ năng này
- `scripts/scale_recipe.py` - đọc tệp CSV nguyên liệu, quy đổi theo khẩu phần, hệ số nhân hoặc diện tích khuôn, chuyển đổi sang đơn vị chuẩn Mỹ (US), hệ mét hoặc gam, làm tròn về các mức đong đếm trong bếp và liệt kê các lưu ý (chỉ dùng thư viện chuẩn Python 3)
- `references/conversions-and-densities.md` - thể tích và trọng lượng chuẩn Mỹ/hệ mét, số gam trên mỗi cốc (cup) cho khoảng 30 nguyên liệu phổ biến, kích cỡ trứng, nhiệt độ lò nướng kèm chế độ quạt đối lưu (fan) và mức vạch gas (gas mark), cùng diện tích các loại khuôn
- `references/baking-scaling-caveats.md` - những gì thay đổi tuyến tính, trứng, chất tạo nở, kích thước khuôn và thời gian nướng, các lưu ý khi nấu ăn (không phải làm bánh) và quy tắc làm tròn
- `templates/scaled-recipe.md` - mẫu công thức đã quy đổi để gửi lại cho người dùng
- `examples/fudgy-brownies-8x8.csv` - công thức bánh brownie cho khuôn 8x8 (16 miếng), được sử dụng trong phần ví dụ
## Quy trình làm việc
### 1. Thu thập công thức và mục tiêu
Hỏi danh sách nguyên liệu kèm theo định lượng và đơn vị, sản lượng (yield) hoặc khuôn gốc, và mục tiêu cần đạt: số khẩu phần, hệ số nhân, hoặc một chiếc khuôn khác. Hỏi xem đây có phải là công thức làm bánh hay không, họ muốn dùng đơn vị nào (cup, gam, hay giữ nguyên như bản gốc), và công thức có nguồn gốc từ đâu nếu kích cỡ cốc (cup), thìa canh (tablespoon) hoặc trứng có thể khác biệt (Mỹ, Anh, Úc). Đưa các nguyên liệu vào tệp CSV với các cột `quantity,unit,ingredient,note`.
### 2. Chạy trình quy đổi (scaler)
```
python3 scripts/scale_recipe.py examples/fudgy-brownies-8x8.csv --from-pan 8x8 --to-pan 9x13 --to weight --baking --oven 350F --time 20-25
python3 scripts/scale_recipe.py stew.csv --servings 4 10 --to metric
python3 scripts/scale_recipe.py cake.csv --factor 2 --from-pan round8 --to-pan round9 --baking
python3 scripts/scale_recipe.py --self-test
```
Các loại khuôn: `8x8`, `9x13`, `20x30cm`, `round9`, `round23cm`, `loaf9x5`. Nếu chỉ truyền thông số khuôn, hệ số nhân sẽ là tỷ lệ diện tích giữa hai khuôn. Nếu truyền hệ số nhân hoặc khẩu phần kèm theo thông số khuôn, kịch bản sẽ kiểm tra xem độ dày của lớp bột (batter depth) thay đổi như thế nào. Mã thoát (Exit) 1 có nghĩa là có lưu ý mức HIGH (độ dày bột thay đổi hơn 1.5 lần), mã thoát 2 có nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Kiểm tra và điều chỉnh
- Đọc mọi lưu ý (caveat) và chuyển chúng thành hướng dẫn cụ thể (xem `references/baking-scaling-caveats.md`): cân phần trứng lẻ, nướng thành nhiều mẻ, đổi khuôn, hạ nhiệt độ lò, nếm thử trước khi cho toàn bộ lượng muối.
- Đối với các nguyên liệu không có dữ liệu khối lượng riêng (NO_DENSITY), hãy giữ nguyên đơn vị thể tích hoặc tra cứu trọng lượng của chúng; tuyệt đối không đoán mò đối với các nhãn hiệu muối hoặc chất tạo nở.
- Kiểm tra tính hợp lý của kết quả: khuôn có chứa vừa lượng bột không (đầy tối đa 2/3 khuôn đối với bánh bông lan/cake)? Việc làm tròn có làm lệch một nguyên liệu quan trọng quá 5 % hay không? Nếu có, hãy cung cấp định lượng bằng gam.
### 4. Bàn giao kết quả
Điền vào mẫu `templates/scaled-recipe.md`: hệ số nhân, sản lượng (yield), khuôn, nhiệt độ lò tính bằng °F và °C (có quạt đối lưu), nguyên liệu theo đơn vị của người nấu kèm đơn vị thay thế trong ngoặc vuông, các thay đổi trong cách làm, các lưu ý và cách kiểm tra độ chín thay vì một mốc thời gian cố định.
## Kết quả ví dụ
Tệp `examples/fudgy-brownies-8x8.csv` được chuyển từ khuôn 8x8 sang khuôn 9x13: hệ số nhân x1.83 (từ 64 lên 117 inch vuông), độ dày lớp bột giữ nguyên, vì vậy nướng ở nhiệt độ 350°F / 180°C (quạt đối lưu 160°C) và bắt đầu kiểm tra ở phút thứ 20. Tính theo gam: bơ 205 g, đường 365 g, bột cacao 52 g, bột mì 115 g, sô-cô-la chip 235 g, muối 2.5 g, bột nở (baking powder) 2 g, vani 7.5 g. Trứng được nhân lên thành 3.66 quả (mức MEDIUM EGG_FRACTION): sử dụng 3 quả trứng cộng với 33 g trứng đánh tan. Việc nhân đôi công thức nhưng lại nướng trong khuôn tròn 9 inch (có diện tích xấp xỉ khuôn 8x8) sẽ làm tăng gấp đôi độ dày lớp bột (mức HIGH DEPTH_CHANGE, mã thoát 1).
## Quy tắc
- Hiển thị hệ số nhân và cách bạn tính ra nó (tỷ lệ khẩu phần hoặc diện tích khuôn) trước khi liệt kê định lượng nguyên liệu.
- Tuyệt đối không nhân thời gian nướng hoặc thời gian nấu với hệ số nhân; hãy đưa ra cách kiểm tra độ chín và thời điểm bắt đầu kiểm tra sớm.
- Giữ nguyên tỷ lệ muối và chất tạo nở trong làm bánh; trong nấu ăn thông thường, chỉ thêm khoảng ba phần tư lượng gia vị mạnh đã quy đổi rồi nếm thử.
- Ưu tiên đơn vị gam khi làm bánh và nói rõ rằng khối lượng riêng chỉ là mức trung bình; hãy tin dùng số gam của tác giả công thức khi có sẵn.
- Hãy hỏi lại trước khi mặc định sử dụng cốc (cup) chuẩn Mỹ hoặc cỡ trứng chuẩn Mỹ cho các công thức đến từ những quốc gia khác.
- Nhắc đến vấn đề an toàn thực phẩm đối với các mẻ nấu lớn (làm nguội nhanh trong các khay nông) khi tăng khẩu phần các món nấu chín lên nhiều lần.
FILE:references/conversions-and-densities.md
# Bảng chuyển đổi và khối lượng riêng
## Thể tích (Hệ đo lường thông dụng của Mỹ - US customary)
| Đơn vị | ml | Tương đương |
|---|---|---|
| 1 tsp (thìa cà phê) | 4.93 | 1/3 tbsp |
| 1 tbsp (thìa canh) | 14.79 | 3 tsp |
| 1 fl oz (ounce lỏng) | 29.57 | 2 tbsp |
| 1 cup (cốc) | 236.59 | 16 tbsp, 8 fl oz |
| 1 pint | 473.18 | 2 cups |
| 1 quart | 946.35 | 4 cups |
Các loại cốc (cup) khác: cốc hệ mét (các công thức của Úc, New Zealand, Canada) = 250 ml; pint hệ Anh (UK imperial pint) = 568 ml; thìa canh của Úc = 20 ml (4 tsp). Hãy hỏi xuất xứ của công thức khi kích thước cốc hoặc thìa canh đóng vai trò quan trọng (làm bánh, muối, chất tạo nở).
## Trọng lượng
| Đơn vị | g |
|---|---|
| 1 oz | 28.35 |
| 1 lb | 453.6 (16 oz) |
| 1 thanh bơ (1 stick butter - Mỹ) | 113 (1/2 cup, 8 tbsp) |
Đơn vị "oz" trong công thức là trọng lượng trừ khi được ghi rõ là "fl oz". Đối với nước và sữa, 8 fl oz nặng khoảng 8 oz; đối với bột mì, 8 fl oz nặng khoảng 4.4 oz.
## Số gam trên mỗi cốc chuẩn Mỹ (đong bằng cách xúc thìa và gạt ngang, được sử dụng bởi kịch bản)
| Nguyên liệu | g/cup | g/tbsp | g/tsp |
|---|---|---|---|
| Bột mì đa dụng (All-purpose / plain flour) | 125 | 7.8 | 2.6 |
| Bột bánh mì (Bread flour) | 127 | 7.9 | |
| Bột làm bánh ngọt (Cake flour) | 114 | 7.1 | |
| Bột mì nguyên cám (Whole wheat flour) | 113 | 7.1 | |
| Bột hạnh nhân (Almond flour) | 96 | 6 | |
| Tinh bột ngô (Cornstarch / cornflour) | 112 | 7 | |
| Đường cát trắng hoặc đường tinh luyện mịn (Granulated / caster sugar) | 200 | 12.5 | 4.2 |
| Đường nâu, nén chặt (Brown sugar, packed) | 213 | 13.3 | |
| Đường bột (Powdered / icing sugar) | 120 | 7.5 | |
| Bơ (Butter) | 227 | 14.2 | |
| Nước (Water) | 237 | 14.8 | 4.9 |
| Sữa tươi, buttermilk, kem chua (Milk, buttermilk, sour cream) | 242 | 15.1 | |
| Kem béo (Heavy cream) | 238 | 14.9 | |
| Sữa chua (Yogurt) | 245 | 15.3 | |
| Dầu thực vật hoặc dầu hạt cải (Vegetable / canola oil) | 218 | 13.6 | |
| Dầu ô liu (Olive oil) | 216 | 13.5 | |
| Mật ong, si-rô vàng (Honey, golden syrup) | 340 | 21 | 7 |
| Si-rô cây phong (Maple syrup) | 322 | 20 | |
| Bột cacao (Cocoa powder) | 85 | 5.3 | |
| Yến mạch cán dẹt (Rolled oats) | 90 | | |
| Sô-cô-la chip (Chocolate chips) | 170 | | |
| Gạo trắng sống (Raw white rice) | 185 | | |
| Các loại hạt băm nhỏ / hạt óc chó (Chopped nuts / walnuts) | 120 | | |
| Hạt hồ đào (Pecans) | 110 | | |
| Nho khô (Raisins) | 150 | | |
| Bơ đậu phộng (Peanut butter) | 270 | 17 | |
| Muối ăn / muối hạt mịn (Table / fine salt) | 288 | 18 | 6 |
| Muối nở (Baking soda) | 230 | | 4.8 |
| Bột nở (Baking powder) | 192 | | 4 |
| Men nở lấy liền hoặc men khô kích hoạt (Instant / active dry yeast) | 149 | | 3.1 |
| Tinh chất vani (Vanilla extract) | 202 | | 4.2 |
| Bột quế (Ground cinnamon) | 125 | | 2.6 |
Lưu ý:
- Bột mì múc trực tiếp từ túi bằng cốc đong có thể nặng từ 140 đến 150 g mỗi cốc. Nếu tác giả công thức cung cấp số gam, hãy tin dùng số gam đó.
- Muối Kosher phụ thuộc vào thương hiệu: Diamond Crystal nặng khoảng 2.8 g/tsp, Morton khoảng 5 g/tsp, muối ăn thông thường khoảng 6 g/tsp. Việc đổi thương hiệu muối nhưng đong theo thể tích có thể làm tăng gấp đôi lượng muối. Kịch bản sẽ giữ nguyên "kosher salt" ở đơn vị thể tích và đưa ra thông báo rõ ràng.
- Bảng trên là mức trung bình thực tế; các bảng công bố khác nhau có thể chênh lệch khoảng 5 %. Mức này hoàn toàn ổn đối với việc nấu ăn và hầu hết các món bánh; riêng đối với bánh macaron, bánh mì và kẹo, hãy dùng cân.
## Trứng
| Kích cỡ (Mỹ) | Trọng lượng bỏ vỏ |
|---|---|
| Cỡ lớn (Large) | 50 g (lòng đỏ 18 g, lòng trắng 32 g), khoảng 3 tbsp sau khi đánh tan |
| Cỡ vừa (Medium) | 44 g |
| Cỡ rất lớn (Extra large) | 56 g |
| Cỡ đại (Jumbo) | 63 g |
Trứng cỡ "large" của Anh/Châu Âu (63 đến 73 g tính cả vỏ) nặng khoảng 58 g khi bỏ vỏ: một quả trứng cỡ large của Anh gần với cỡ extra large của Mỹ hơn.
## Nhiệt độ lò nướng
| °F | °C | °C có quạt (Fan) | Mức Gas (Gas mark) |
|---|---|---|---|
| 300 | 150 | 130 | 2 |
| 325 | 160 | 140 | 3 |
| 350 | 180 | 160 | 4 |
| 375 | 190 | 170 | 5 |
| 400 | 200 | 180 | 6 |
| 425 | 220 | 200 | 7 |
| 450 | 230 | 210 | 8 |
Lò nướng có quạt (đối lưu): hạ thấp khoảng 20 °C / 25 °F, hoặc giữ nguyên nhiệt độ và kiểm tra bánh sớm hơn.
## Diện tích khuôn
| Khuôn | Diện tích (inch vuông - sq in) | Diện tích (cm²) |
|---|---|---|
| Vuông 8x8 | 64 | 413 |
| Vuông 9x9 | 81 | 523 |
| Chữ nhật 9x13 | 117 | 755 |
| Khay bánh cuộn (jelly roll) 10x15 | 150 | 968 |
| Tròn 8 in | 50 | 324 |
| Tròn 9 in | 64 | 410 |
| Tròn 10 in | 79 | 507 |
| Khuôn bánh mì gối (loaf) 9x5 | 45 | 290 |
| Tròn 20 cm | 49 | 314 |
| Tròn 23 cm | 64 | 415 |
| Vuông 20x20 cm | 62 | 400 |
Diện tích khuôn tròn = 3.14 x bán kính². Một chiếc khuôn vuông 8x8 và một chiếc khuôn tròn 9 in chứa lượng bột gần như tương đương nhau.
FILE:references/baking-scaling-caveats.md
# Các lưu ý khi quy đổi công thức
## Những gì thay đổi theo tỷ lệ tuyến tính
Bột mì, đường, chất béo, chất lỏng, sô-cô-la, các loại hạt, trái cây và trứng (tính theo trọng lượng) thay đổi tuyến tính theo hệ số nhân. Trong làm bánh, muối và chất tạo nở cũng bắt đầu ở mức định lượng tuyến tính: phản ứng hóa học phụ thuộc vào tỷ lệ so với bột mì, vì vậy hãy giữ nguyên tỷ lệ đó.
## Trứng
- Phần lẻ của trứng: đánh tan trứng, đem cân và lấy đúng số gam cần thiết (trứng cỡ lớn = 50 g khi bỏ vỏ). 3.66 quả trứng = 3 quả trứng + 33 g trứng đánh tan (khoảng 2 tbsp).
- Nếu bạn chọn cách làm tròn: nhiều trứng hơn sẽ làm bánh bông lan (cake) và bánh brownie cứng cáp hơn, xốp giống bánh bông lan hơn hoặc béo ngậy hơn; ít trứng hơn sẽ làm bánh đặc hơn và dễ vỡ hơn. Khi phân vân, hãy làm tròn lên đối với bánh bông lan, và làm tròn xuống đối với bánh brownie ẩm đặc (fudgy brownies) và bánh quy.
- Các công thức được viết ở quốc gia khác có thể sử dụng kích cỡ trứng khác (xem bảng chuyển đổi).
## Chất tạo nở (bột nở, muối nở, men nở)
- Bắt đầu theo tỷ lệ tuyến tính và đong đếm thật chính xác; ở các hệ số nhân nhỏ, sai số 1/8 tsp là rất lớn, vì vậy hãy cân trên cân tiểu ly có độ chia 0.1 g.
- Đối với các hệ số nhân lớn hơn 2, quá trình trộn bột và nhiệt lượng lò nướng không tăng theo tỷ lệ tuyến tính: khối bột lớn khó trộn đều hơn và phần tâm bánh nóng lên chậm hơn. Hãy chia thành các mẻ riêng biệt theo kích thước gốc bất cứ khi nào có thể.
- Nếu lớp bột cuối cùng dày hơn so với bản gốc (cùng một khuôn nhưng nhiều bột hơn), phần tâm bánh sẽ nở chậm hơn. Đừng "chữa cháy" việc này bằng cách cho thêm chất tạo nở; hãy dùng khuôn rộng hơn hoặc hạ nhiệt độ lò và nướng lâu hơn.
- Bánh mì: men nở thay đổi tuyến tính theo lượng bột mì; thời gian ủ nở phụ thuộc vào nhiệt độ khối bột chứ không phụ thuộc vào khối lượng bột.
## Kích thước khuôn và thời gian nướng
- Quy đổi theo diện tích khuôn giúp giữ nguyên độ dày của lớp bột, do đó thời gian nướng gần như không đổi. Hãy bắt đầu kiểm tra sớm hơn 5 phút.
- Cùng một lượng bột công thức nhưng cho vào khuôn khác: tỷ lệ độ dày = diện tích khuôn cũ / diện tích khuôn mới. Lớp bột dày hơn cần thời gian nướng lâu hơn ở nhiệt độ thấp hơn (thấp hơn khoảng 25 °F / 15 °C); lớp bột mỏng hơn sẽ chín nhanh hơn.
- Chỉ đổ bột vào khuôn bánh bông lan tối đa 2/3 khuôn, và khoảng một nửa khuôn đối với bánh brownie.
- Khuôn thủy tinh và khuôn kim loại tối màu làm chín phần rìa bánh nhanh hơn: hãy hạ nhiệt độ lò xuống 25 °F / 15 °C.
- Làm bánh cupcake từ công thức bánh bông lan: nướng từ 18 đến 22 phút ở cùng mức nhiệt độ; một công thức bánh khuôn 9x13 làm được khoảng 24 chiếc bánh cupcake.
- Tuyệt đối không nhân thời gian nướng với hệ số quy đổi. Hãy sử dụng phương pháp kiểm tra độ chín từ công thức gốc (cắm tăm rút ra còn dính vụn ẩm đối với bánh brownie, rút tăm sạch khô đối với bánh bông lan, nhiệt độ bên trong đạt 93 đến 99 °C / 200 đến 210 °F đối với bánh mì).
## Nấu ăn thông thường (không phải làm bánh)
- Gia vị: chỉ cho khoảng ba phần tư lượng muối, ớt và các loại gia vị mạnh đã nhân theo hệ số, sau đó nếm thử. Một chiếc nồi lớn hơn sẽ có tốc độ bay hơi khác đi.
- Chất lỏng trong các món hầm và súp: sự bay hơi phụ thuộc vào diện tích bề mặt của nồi chứ không phải thể tích, vì vậy một món hầm được nhân đôi nấu trong nồi rộng hơn có thể cần lượng chất lỏng bổ sung ít hơn mức gấp đôi (2x).
- Áp chảo và chiên rán: hãy chia thành từng mẻ; chảo quá chật sẽ làm thức ăn bị hấp chín bằng hơi nước thay vì vàng xém.
- Thời gian: một nồi lớn gấp đôi sẽ mất nhiều thời gian hơn để đun sôi và làm nguội, nhưng thời gian ninh nhỏ lửa (simmering) khi đã sôi thì gần như tương đương. Các món thịt quay/nướng tảng phụ thuộc vào độ dày của miếng thịt chứ không phải tổng trọng lượng: hãy dùng nhiệt kế đo thịt.
- An toàn thực phẩm: làm nguội nhanh các mẻ thức ăn lớn (chia nhỏ ra các khay nông) và hâm nóng lại ở nhiệt độ tối thiểu 74 °C / 165 °F.
## Quy tắc làm tròn cho căn bếp thực tế
- Đơn vị đo lường Mỹ (US measures): cốc (cup) làm tròn theo nấc 1/4 và 1/3, phần dư chuyển sang thìa canh (tbsp) làm tròn theo nấc một nửa (1/2), và thìa cà phê (tsp) làm tròn xuống tối thiểu 1/8. Nhỏ hơn 1/8 tsp = một nhúm (a pinch).
- Hệ mét: làm tròn đến 0.5 g đối với lượng dưới 10 g, 1 g đối với lượng dưới 100 g, và 5 g đối với lượng dưới 1 kg.
- Giữ nguyên hệ thống đơn vị gốc trừ khi người nấu yêu cầu đổi; luôn đề xuất đơn vị gam cho các món bánh.
- Kiểm tra lại kết quả sau khi làm tròn so với tỷ lệ gốc (ví dụ tỷ lệ giữa bột mì và bơ); nếu việc làm tròn làm dịch chuyển một nguyên liệu quan trọng quá 5 %, hãy cung cấp số gam chính xác.
FILE:templates/scaled-recipe.md
# <Tên công thức>, đã quy đổi <từ khuôn 8x8 sang khuôn 9x13 / từ 4 lên 10 khẩu phần>
**Hệ số nhân:** x<1.83> **Sản lượng:** <24 miếng brownie / 10 khẩu phần> **Đơn vị:** <gam kèm đơn vị đo lường Mỹ trong ngoặc>
**Khuôn:** <9x13 in (23x33 cm), có lót giấy nến> **Lò nướng:** <350°F / 180°C (quạt đối lưu 160°C)> **Thời gian nướng:** <bắt đầu kiểm tra ở phút 20; thường từ 20-25 phút>
## Nguyên liệu
| Nguyên liệu | Định lượng | Đơn vị đo lường Mỹ |
|---|---|---|
| <bơ lạt, đun chảy> | <205 g> | <3/4 cup + 2 1/2 tbsp> |
| <trứng gà cỡ lớn (large)> | <3 quả + 33 g đánh tan (khoảng 2 tbsp)> | |
## Thay đổi trong cách làm so với bản gốc
- <Đánh tan 4 quả trứng, cân lấy 183 g, giữ lại phần thừa để phết mặt bánh (egg wash)>
- <Trộn trong âu lớn hơn; chia bột mì thành hai lần để trộn (fold) vào hỗn hợp>
- <Giữ nguyên thời gian nướng vì độ dày lớp bột không đổi; kiểm tra ở phút thứ 20>
## Các lưu ý (Caveats)
- <EGG_FRACTION: ...>
- <Thương hiệu muối Kosher: ...>
## Kiểm tra độ chín
<Cắm tăm cách tâm bánh 2 cm, khi rút ra thấy có vụn bánh ẩm bám vào chứ không phải bột ướt.>
FILE:examples/fudgy-brownies-8x8.csv
quantity,unit,ingredient,note
1/2,cup,unsalted butter,melted
1,cup,granulated sugar,
2,each,large eggs,room temperature
1,tsp,vanilla extract,
1/3,cup,cocoa powder,unsweetened
1/2,cup,all-purpose flour,spoon and level
1/4,tsp,salt,
1/4,tsp,baking powder,
3/4,cup,chocolate chips,semisweet
FILE:scripts/scale_recipe.py
#!/usr/bin/env python3
"""Recipe scaling and unit converter (Python 3 standard library only).
Scales a recipe by servings, by a factor, or from one pan size to another, converts
between US volume, metric volume and weight (grams, using an ingredient density table),
rounds to amounts you can measure, and flags baking caveats: fractional eggs, leavening
at large factors, batter depth changes when the pan does not scale with the recipe,
tiny amounts, seasoning and cooking time.
Input CSV columns: quantity,unit,ingredient,note
quantity: 2 | 1.5 | 3/4 | 1 1/2 | ½ unit: tsp tbsp cup fl oz pint quart ml l g kg oz lb stick
each (or empty) for counted items such as eggs.
Usage:
python3 scale_recipe.py recipe.csv --servings 4 10 [--to us|metric|weight|keep]
python3 scale_recipe.py recipe.csv --from-pan 8x8 --to-pan 9x13 --baking --oven 350F --time 20-25
python3 scale_recipe.py recipe.csv --factor 0.5 --to weight --json
python3 scale_recipe.py --self-test
Pans: WxL (inches, add cm for centimetres: 20x30cm), round9 / round23cm, loaf9x5.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, csv, io, json, math, re, sys
ML = {"tsp": 4.92892, "tbsp": 14.7868, "cup": 236.588, "fl oz": 29.5735, "pint": 473.176,
"quart": 946.353, "ml": 1.0, "l": 1000.0}
G = {"g": 1.0, "kg": 1000.0, "oz": 28.3495, "lb": 453.592, "stick": 113.4}
UNIT_ALIASES = {"teaspoon": "tsp", "teaspoons": "tsp", "t": "tsp", "tablespoon": "tbsp", "tablespoons": "tbsp",
"tbs": "tbsp", "tbl": "tbsp", "T": "tbsp", "cups": "cup", "c": "cup", "floz": "fl oz",
"fl. oz": "fl oz", "fluid ounce": "fl oz", "fluid ounces": "fl oz", "pints": "pint", "pt": "pint",
"quarts": "quart", "qt": "quart", "milliliter": "ml", "milliliters": "ml", "mL": "ml",
"liter": "l", "liters": "l", "litre": "l", "litres": "l", "L": "l", "gram": "g", "grams": "g",
"kilogram": "kg", "kilograms": "kg", "ounce": "oz", "ounces": "oz", "pound": "lb", "pounds": "lb",
"lbs": "lb", "sticks": "stick", "": "each", "x": "each", "whole": "each", "pc": "each",
"pcs": "each", "piece": "each", "pieces": "each", "clove": "each", "cloves": "each",
"large": "each", "medium": "each", "small": "each", "pinch": "pinch", "pinches": "pinch",
"dash": "pinch"}
# grams per US cup (spoon-and-level). Longest matching name wins.
DENSITY = {
"all-purpose flour": 125, "plain flour": 125, "flour": 125, "bread flour": 127, "cake flour": 114,
"whole wheat flour": 113, "almond flour": 96, "cornstarch": 112, "cornflour": 112,
"granulated sugar": 200, "caster sugar": 200, "sugar": 200, "brown sugar": 213,
"powdered sugar": 120, "icing sugar": 120, "confectioners sugar": 120,
"butter": 227, "water": 237, "milk": 242, "buttermilk": 242, "heavy cream": 238, "cream": 238,
"sour cream": 242, "yogurt": 245, "vegetable oil": 218, "canola oil": 218, "olive oil": 216, "oil": 218,
"honey": 340, "maple syrup": 322, "golden syrup": 340, "cocoa powder": 85, "cocoa": 85,
"rolled oats": 90, "oats": 90, "chocolate chips": 170, "rice": 185, "chopped nuts": 120,
"walnuts": 120, "pecans": 110, "raisins": 150, "peanut butter": 270,
"table salt": 288, "fine salt": 288, "salt": 288, "kosher salt": None,
"baking soda": 230, "bicarbonate of soda": 230, "baking powder": 192, "instant yeast": 149,
"active dry yeast": 149, "yeast": 149, "vanilla extract": 202, "vanilla": 202,
"ground cinnamon": 125, "cinnamon": 125,
}
LEAVENERS = ("baking soda", "bicarbonate", "baking powder", "yeast", "cream of tartar")
SEASONINGS = ("salt", "pepper", "chili", "chilli", "cayenne", "pepper flakes", "garlic powder", "curry", "cumin", "paprika")
EGG_G = {"egg": 50.0, "yolk": 18.0, "white": 32.0} # US large egg out of shell
UNICODE_FRAC = {"½": .5, "¼": .25, "¾": .75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": .125, "⅜": .375, "⅝": .625, "⅞": .875}
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
def parse_qty(s):
s = s.strip()
for ch, v in UNICODE_FRAC.items():
if ch in s:
rest = s.replace(ch, "").strip()
return (float(rest) if rest else 0.0) + v
m = re.fullmatch(r"(\d+)\s+(\d+)/(\d+)", s)
if m:
return int(m[1]) + int(m[2]) / int(m[3])
m = re.fullmatch(r"(\d+)/(\d+)", s)
if m:
return int(m[1]) / int(m[2])
m = re.fullmatch(r"\d+(?:\.\d+)?|\.\d+", s)
if m:
return float(s)
raise ValueError(f"cannot read quantity {s!r}")
def norm_unit(u):
u = u.strip()
if u in UNIT_ALIASES:
return UNIT_ALIASES[u]
low = u.lower().rstrip(".")
if low in ML or low in G or low in ("each", "pinch"):
return low
if low in UNIT_ALIASES:
return UNIT_ALIASES[low]
raise ValueError(f"unknown unit {u!r}")
def density_for(name):
low = name.lower()
best = None
for key in DENSITY:
if re.search(r"\b" + re.escape(key) + r"\b", low) and (best is None or len(key) > len(best)):
best = key
return (best, DENSITY[best]) if best else (None, None)
def egg_kind(name):
low = name.lower()
if "yolk" in low:
return "yolk"
if "white" in low and "egg" in low:
return "white"
if re.search(r"\beggs?\b", low):
return "egg"
return None
def parse_pan(spec):
s = spec.strip().lower().replace(" ", "").replace("inch", "").replace("in", "").replace('"', "")
cm = s.endswith("cm")
if cm:
s = s[:-2]
k = 1 / 2.54 if cm else 1.0
m = re.fullmatch(r"(?:round)?(\d+(?:\.\d+)?)(?:round)?", s)
if m and "round" in s:
d = float(m[1]) * k
return math.pi * (d / 2) ** 2, f"{spec} round ({d:.1f} in)"
m = re.fullmatch(r"(?:loaf|square|rect)?(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)", s)
if m:
a, b = float(m[1]) * k, float(m[2]) * k
return a * b, f"{spec} ({a:.1f} x {b:.1f} in)"
raise ValueError(f"cannot read pan size {spec!r} (use 9x13, 20x30cm, round9, round23cm, loaf9x5)")
def frac_str(x, denoms=(2, 3, 4, 8)):
whole = int(x)
rest = x - whole
best = (abs(rest), 0, 1)
for d in denoms:
n = round(rest * d)
if 0 < n < d and abs(rest - n / d) < best[0] - 1e-9:
best = (abs(rest - n / d), n, d)
if abs(rest - 1) < best[0]:
return str(whole + 1)
_, n, d = best
if n == 0:
return str(whole)
g = math.gcd(n, d)
return (f"{whole} " if whole else "") + f"{n // g}/{d // g}"
def frac_val(s):
return parse_qty(s)
def cup_word(x):
return "cups" if x > 1 + 1e-9 else "cup"
def fmt_us_volume(ml):
cups = ml / ML["cup"]
if cups >= 0.25 - 1e-9:
s = frac_str(cups, (2, 3, 4))
err = abs(frac_val(s) - cups) * ML["cup"]
if err >= ML["tbsp"] * 0.75:
base = math.floor(cups * 4) / 4
tb = (cups - base) * ML["cup"] / ML["tbsp"]
bs = frac_str(base, (4,))
return (f"{bs} {cup_word(base)} + " if base else "") + f"{frac_str(tb, (2,))} tbsp"
return f"{s} {cup_word(frac_val(s))}"
tbsp = ml / ML["tbsp"]
if tbsp >= 1 - 1e-9:
return f"{frac_str(tbsp, (2,))} tbsp"
tsp = ml / ML["tsp"]
if tsp < 1 / 16:
return "pinch"
return f"{frac_str(tsp, (2, 4, 8))} tsp"
def round_metric(x):
if x < 10:
return round(x * 2) / 2
if x < 100:
return round(x)
if x < 1000:
return round(x / 5) * 5
return round(x / 10) * 10
def fmt_num(x):
return f"{x:g}"
def fmt_metric(amount, unit):
v = round_metric(amount)
if v >= 2000:
return f"{fmt_num(v / 1000)} {'kg' if unit == 'g' else 'l'}"
return f"{fmt_num(v)} {unit}"
def fmt_us_weight(g):
oz = g / G["oz"]
if oz >= 16:
lb = oz / 16
return f"{frac_str(lb, (4,))} lb"
return f"{frac_str(oz, (2, 4))} oz" if oz >= 1 else f"{fmt_num(round(oz, 2))} oz"
def convert(row, factor, target):
"""Return dict with scaled display and notes for one ingredient row."""
qty, unit, name = row["qty"] * factor, row["unit"], row["ingredient"]
key, dens = density_for(name)
egg = egg_kind(name)
out = {"ingredient": name, "original": row["original"], "scaled_qty": qty, "unit": unit, "notes": []}
if unit == "each":
if egg:
g = qty * EGG_G[egg]
out["display"] = f"{fmt_num(round(qty, 2))} ({fmt_metric(g, 'g')} beaten)"
else:
out["display"] = fmt_num(round(qty, 2))
return out
if unit == "pinch":
out["display"] = f"{fmt_num(round(qty, 2))} pinch"
return out
if unit in ML:
ml = qty * ML[unit]
out["ml"] = ml
if target == "weight":
if dens:
out["display"] = fmt_metric(ml / ML["cup"] * dens, "g")
out["also"] = fmt_us_volume(ml)
else:
out["display"] = fmt_metric(ml, "ml")
out["also"] = fmt_us_volume(ml)
out["notes"].append("NO_DENSITY")
elif target == "metric" or (target == "keep" and unit in ("ml", "l")):
out["display"] = fmt_metric(ml, "ml")
out["also"] = fmt_us_volume(ml)
else:
out["display"] = fmt_us_volume(ml)
out["also"] = fmt_metric(ml, "ml") if not dens else fmt_metric(ml / ML["cup"] * dens, "g")
return out
g = qty * G[unit]
out["g"] = g
if target == "us" or (target == "keep" and unit in ("oz", "lb", "stick")):
if unit == "stick" and target == "keep":
out["display"] = f"{frac_str(qty, (2, 4))} stick ({fmt_metric(g, 'g')})"
else:
out["display"] = fmt_us_weight(g)
out["also"] = fmt_metric(g, "g")
else:
out["display"] = fmt_metric(g, "g")
if dens:
out["also"] = fmt_us_volume(g / dens * ML["cup"])
return out
def read_recipe(text):
rows = []
reader = csv.DictReader(io.StringIO(text.lstrip("\ufeff")))
need = {"quantity", "unit", "ingredient"}
if not reader.fieldnames or not need <= {f.strip().lower() for f in reader.fieldnames}:
raise ValueError("CSV header must include quantity,unit,ingredient (note optional)")
for i, r in enumerate(reader, 2):
r = {k.strip().lower(): (v or "").strip() for k, v in r.items() if k}
if not r.get("ingredient"):
continue
try:
qty = parse_qty(r["quantity"])
unit = norm_unit(r.get("unit", ""))
except ValueError as e:
raise ValueError(f"line {i}: {e}")
orig = f"{r['quantity']} {r.get('unit', '')}".strip()
rows.append({"qty": qty, "unit": unit, "ingredient": r["ingredient"], "note": r.get("note", ""), "original": orig})
if not rows:
raise ValueError("no ingredient rows")
return rows
def oven(temp):
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*°?\s*([CcFf])\s*", temp)
if not m:
raise ValueError(f"cannot read oven temperature {temp!r} (use 350F or 180C)")
v, u = float(m[1]), m[2].upper()
if u == "F":
c = (v - 32) * 5 / 9
return f"{v:g}°F = {round(c / 10) * 10:g}°C (fan oven {round(c / 10) * 10 - 20:g}°C)"
f = v * 9 / 5 + 32
return f"{v:g}°C = {round(f / 25) * 25:g}°F"
def scale(rows, factor, target="keep", baking=False, depth_ratio=None, pan_change=None, bake_time=None):
items = [convert(r, factor, target) for r in rows]
findings = []
for it, r in zip(items, rows):
name = r["ingredient"].lower()
egg = egg_kind(name)
if egg and r["unit"] == "each":
q = it["scaled_qty"]
whole = math.floor(q + 1e-9)
if abs(q - round(q)) > 0.15:
extra = (q - whole) * EGG_G[egg]
findings.append(("MEDIUM", "EGG_FRACTION", r["ingredient"],
f"scales to {q:.2f} {egg}s",
f"Use {whole} whole {egg}{'s' if whole != 1 else ''} plus {round(extra):g} g beaten {egg} (a large {egg} is about {EGG_G[egg]:g} g out of the shell), or round to {round(q)} and expect a {'richer' if round(q) > q else 'drier'} result."))
if "NO_DENSITY" in it["notes"]:
findings.append(("LOW", "NO_DENSITY", r["ingredient"], "no density in the table, so it stays in volume",
"Weigh it once, or look up grams per cup; for kosher salt the brand matters (Diamond Crystal about 136 g/cup, Morton about 240 g/cup)."))
ml = it.get("ml")
if ml is not None and ml / ML["tsp"] < 1 / 8 and r["unit"] in ML:
findings.append(("LOW", "TINY_AMOUNT", r["ingredient"], f"scales to {ml / ML['tsp']:.2f} tsp",
"Use a pinch or a 1/8 tsp measure; for leavening weigh on a 0.1 g scale."))
if baking and any(w in name for w in LEAVENERS) and (factor > 2 or factor < 0.5):
findings.append(("LOW", "LEAVENING", r["ingredient"], f"leavening scaled x{factor:.2f}",
"Linear scaling is the starting point; measure exactly (weigh), and for factors above 2 consider separate batches because mixing and oven heat do not scale."))
if not baking and factor > 2 and any(w in name for w in SEASONINGS):
findings.append(("INFO", "SEASONING", r["ingredient"], f"seasoning scaled x{factor:.2f}",
"Add about three quarters, then taste and adjust; evaporation and pot size change how salty or hot it ends up."))
if baking and (factor > 3 or factor < 1 / 3):
findings.append(("MEDIUM", "BIG_FACTOR", "-", f"baking recipe scaled x{factor:.2f}",
"Bake in several batches of the original size, or check mixer bowl capacity; never multiply the bake time."))
if depth_ratio is not None:
sev = "HIGH" if (depth_ratio > 1.5 or depth_ratio < 0.67) else ("MEDIUM" if (depth_ratio > 1.25 or depth_ratio < 0.8) else None)
if sev and baking:
if depth_ratio > 1:
fix = "Batter will be deeper: lower the oven by 25°F/15°C, bake longer and test the centre, keep the pan at most 2/3 full, or use a bigger pan."
else:
fix = "Batter will be thinner: start checking 25 to 40 % earlier, or use a smaller pan."
findings.append((sev, "DEPTH_CHANGE", "-", f"batter depth changes x{depth_ratio:.2f} versus the original pan", fix))
if baking and pan_change:
if depth_ratio is None or 0.9 <= depth_ratio <= 1.1:
tip = "Batter depth stays about the same, so the bake time stays about the same; start checking 5 minutes early. Glass or dark pans bake faster: lower 25°F/15°C."
elif 0.8 <= depth_ratio <= 1.25:
tip = f"Batter depth changes slightly (x{depth_ratio:.2f}); {'bake a few minutes longer' if depth_ratio > 1 else 'start checking a few minutes early'} and test the centre."
else:
tip = "See DEPTH_CHANGE for time and temperature."
if bake_time:
tip = f"Original {bake_time} min. " + tip
findings.append(("INFO", "PAN_TIME", "-", pan_change, tip))
if not baking and factor > 1.5:
findings.append(("INFO", "COOK_TIME", "-", f"scaled x{factor:.2f}",
"Cooking time does not scale with quantity: brown meat in batches, allow longer to come to a boil, and use a pot that keeps the depth similar."))
findings.sort(key=lambda f: SEV_ORDER[f[0]])
return items, [dict(zip(("severity", "code", "ingredient", "problem", "fix"), f)) for f in findings]
def render(path, factor, how, target, items, findings, oven_line=None):
out = [f"# Scaled recipe: {path}", "", f"Scale: {how} -> factor x{factor:.3f} Units: {target}", ""]
out += ["| Ingredient | Original | Scaled | Also |", "|---|---|---|---|"]
for it in items:
out.append(f"| {it['ingredient']} | {it['original']} | {it['display']} | {it.get('also', '')} |")
if oven_line:
out += ["", f"Oven: {oven_line}"]
out += ["", "## Notes", ""]
if findings:
out += ["| Severity | Code | Ingredient | Problem | Advice |", "|---|---|---|---|---|"]
for f in findings:
out.append(f"| {f['severity']} | {f['code']} | {f['ingredient']} | {f['problem']} | {f['fix']} |")
else:
out.append("No caveats.")
out += ["", "Weights for volume ingredients use spoon-and-level densities; weigh flour if you can."]
return "\n".join(out)
def self_test():
cases = []
def t(name, cond):
cases.append((name, bool(cond)))
close = lambda a, b, tol=1e-6: abs(a - b) <= tol
t("parse 1 1/2", close(parse_qty("1 1/2"), 1.5))
t("parse 3/4 and .5 and ½", close(parse_qty("3/4"), .75) and close(parse_qty(".5"), .5) and close(parse_qty("1½"), 1.5))
t("bad quantity rejected", raises(lambda: parse_qty("some")))
t("unit aliases", norm_unit("Tablespoons") == "tbsp" and norm_unit("T") == "tbsp" and norm_unit("t") == "tsp" and norm_unit("") == "each")
t("unknown unit rejected", raises(lambda: norm_unit("handful")))
t("longest density match", density_for("light brown sugar")[0] == "brown sugar" and density_for("all-purpose flour")[1] == 125)
t("salt vs kosher salt", density_for("salt")[1] == 288 and density_for("kosher salt")[1] is None)
t("eggs detected", egg_kind("large eggs") == "egg" and egg_kind("egg yolks") == "yolk" and egg_kind("eggplant") is None)
a8 = parse_pan("8x8")[0]; a913 = parse_pan("9x13")[0]
t("rect pan area", close(a8, 64) and close(a913, 117))
t("round pan area", close(parse_pan("round9")[0], math.pi * 20.25))
t("cm pan", close(parse_pan("20x20cm")[0], (20 / 2.54) ** 2))
t("bad pan rejected", raises(lambda: parse_pan("big")))
t("us volume 1 cup", fmt_us_volume(236.588) == "1 cup")
t("us volume 3/4 cup", fmt_us_volume(ML["cup"] * .75) == "3/4 cup")
t("us volume 2/3 cup", fmt_us_volume(ML["cup"] * 2 / 3) == "2/3 cup")
t("us volume cup + tbsp", fmt_us_volume(ML["cup"] * 1.125) == "1 cup + 2 tbsp")
t("fractions reduced, cups plural", fmt_us_volume(ML["cup"] * 0.61) == "1/2 cup + 2 tbsp" and fmt_us_volume(ML["cup"] * 2) == "2 cups")
t("us volume tbsp", fmt_us_volume(ML["tbsp"] * 1.5) == "1 1/2 tbsp")
t("us volume tsp", fmt_us_volume(ML["tsp"] * .5) == "1/2 tsp")
t("us volume pinch", fmt_us_volume(ML["tsp"] * .05) == "pinch")
t("metric rounding", round_metric(7.3) == 7.5 and round_metric(57.4) == 57 and round_metric(228) == 230 and round_metric(1234) == 1230)
t("1 cup flour = 125 g", convert({"qty": 1, "unit": "cup", "ingredient": "all-purpose flour", "original": ""}, 1, "weight")["display"] == "125 g")
t("1 stick butter in us/keep", "1 stick" in convert({"qty": 1, "unit": "stick", "ingredient": "butter", "original": ""}, 1, "keep")["display"])
t("8 oz to grams", convert({"qty": 8, "unit": "oz", "ingredient": "cheese", "original": ""}, 1, "metric")["display"] == "225 g")
t("500 g to us lb", convert({"qty": 500, "unit": "g", "ingredient": "pasta", "original": ""}, 1, "us")["display"] == "1 lb")
t("metric volume", convert({"qty": 1, "unit": "cup", "ingredient": "milk", "original": ""}, 2, "metric")["display"] == "475 ml")
t("no density keeps ml", "NO_DENSITY" in convert({"qty": 1, "unit": "tbsp", "ingredient": "fish sauce", "original": ""}, 1, "weight")["notes"])
rows = read_recipe("quantity,unit,ingredient,note\n2,each,large eggs,\n1/4,tsp,baking soda,\n1,cup,sugar,\n")
t("csv read", len(rows) == 3 and rows[0]["unit"] == "each")
t("csv bad header rejected", raises(lambda: read_recipe("a,b\n1,2\n")))
t("csv bad unit names line", "line 2" in err_of(lambda: read_recipe("quantity,unit,ingredient\n1,handful,x\n")))
_, f = scale(rows, 1.828, baking=True)
t("EGG_FRACTION at x1.83", any(x["code"] == "EGG_FRACTION" and "3 whole eggs plus 33 g" in x["fix"] for x in f))
_, f = scale(rows, 2.0, baking=True)
t("no EGG_FRACTION when whole", not any(x["code"] == "EGG_FRACTION" for x in f))
_, f = scale(rows, 0.1, baking=True)
t("TINY_AMOUNT and BIG_FACTOR", {"TINY_AMOUNT", "BIG_FACTOR", "LEAVENING"} <= {x["code"] for x in f})
_, f = scale(rows, 1.0, baking=True, depth_ratio=1.83)
t("DEPTH_CHANGE HIGH", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "HIGH" for x in f))
_, f = scale(rows, 1.0, baking=True, depth_ratio=1.3)
t("DEPTH_CHANGE MEDIUM", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "MEDIUM" for x in f))
_, f = scale(read_recipe("quantity,unit,ingredient\n1,tsp,salt\n"), 3, baking=False)
t("SEASONING and COOK_TIME for cooking", {"SEASONING", "COOK_TIME"} <= {x["code"] for x in f})
t("oven F to C", oven("350F") == "350°F = 180°C (fan oven 160°C)" and oven("200C") == "200°C = 400°F")
t("findings sorted HIGH first", scale(rows, 1.83, baking=True, depth_ratio=2)[1][0]["severity"] == "HIGH")
passed = sum(ok for _, ok in cases)
for name, ok in cases:
print(("PASS " if ok else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def raises(fn):
try:
fn()
except ValueError:
return True
return False
def err_of(fn):
try:
fn()
except ValueError as e:
return str(e)
return ""
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("recipe", nargs="?")
g = ap.add_mutually_exclusive_group()
g.add_argument("--servings", nargs=2, type=float, metavar=("FROM", "TO"))
g.add_argument("--factor", type=float)
ap.add_argument("--from-pan")
ap.add_argument("--to-pan")
ap.add_argument("--to", default="keep", choices=["keep", "us", "metric", "weight"])
ap.add_argument("--baking", action="store_true", help="apply baking caveats (eggs, leavening, pan depth)")
ap.add_argument("--oven", help="original oven temperature, e.g. 350F or 180C")
ap.add_argument("--time", help="original bake time in minutes, e.g. 20-25")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.recipe:
ap.print_usage(sys.stderr); return 2
try:
rows = read_recipe(open(a.recipe, encoding="utf-8").read())
depth_ratio = pan_change = None
if bool(a.from_pan) != bool(a.to_pan):
raise ValueError("give both --from-pan and --to-pan")
if a.from_pan:
fa, fd = parse_pan(a.from_pan)
ta, td = parse_pan(a.to_pan)
pan_change = f"{fd} -> {td}, area x{ta / fa:.2f}"
if a.servings:
if a.servings[0] <= 0 or a.servings[1] <= 0:
raise ValueError("servings must be positive")
factor, how = a.servings[1] / a.servings[0], f"{a.servings[0]:g} -> {a.servings[1]:g} servings"
elif a.factor is not None:
if a.factor <= 0:
raise ValueError("factor must be positive")
factor, how = a.factor, f"factor {a.factor:g}"
elif a.from_pan:
factor, how = ta / fa, f"pan {a.from_pan} -> {a.to_pan}"
else:
raise ValueError("give --servings FROM TO, --factor F or --from-pan/--to-pan")
if a.from_pan:
depth_ratio = factor * fa / ta
if abs(depth_ratio - 1) < 0.01:
depth_ratio = None
items, findings = scale(rows, factor, a.to, a.baking, depth_ratio, pan_change, a.time)
oven_line = oven(a.oven) if a.oven else None
except (OSError, UnicodeDecodeError, ValueError) as e:
print(f"error: {e}", file=sys.stderr); return 2
if a.json:
print(json.dumps({"factor": factor, "how": how, "units": a.to, "oven": oven_line,
"items": [{k: v for k, v in it.items() if k != "notes"} for it in items],
"findings": findings}, indent=2, ensure_ascii=False))
else:
print(render(a.recipe, factor, how, a.to, items, findings, oven_line))
return 1 if any(f["severity"] == "HIGH" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())