Tính toán lượng sơn cần mua cho một hoặc nhiều phòng cùng chi phí tương ứng. Đo diện tích tường (trừ cửa ra vào và cửa sổ) và trần nhà, áp dụng số lớp sơn, độ phủ, độ nhám bề mặt và tỷ lệ hao hụt, cộng dồn từng loại sơn qua các phòng và chọn cách phối hợp các cỡ thùng sơn rẻ nhất với lượng sơn thừa ít nhất. Cảnh báo các trường hợp đổi từ màu tối sang màu sáng và tường trát mới cần sơn lót, các kế hoạch chỉ sơn một lớp, độ phủ hoặc chiều cao đáng ngờ và lượng sơn thừa quá nhiều. Bao gồm công cụ ước tính viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
name: room-paint-quantity-estimator
description: Tính toán lượng sơn cần mua cho một hoặc nhiều phòng cùng chi phí tương ứng. Đo diện tích tường (trừ cửa ra vào và cửa sổ) và trần nhà, áp dụng số lớp sơn, độ phủ, độ nhám bề mặt và tỷ lệ hao hụt, cộng dồn từng loại sơn qua các phòng và chọn cách phối hợp các cỡ thùng sơn rẻ nhất với lượng sơn thừa ít nhất. Cảnh báo các trường hợp đổi từ màu tối sang màu sáng và tường trát mới cần sơn lót, các kế hoạch chỉ sơn một lớp, độ phủ hoặc chiều cao đáng ngờ và lượng sơn thừa quá nhiều. Bao gồm công cụ ước tính viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
# Công cụ Ước tính Lượng Sơn Phòng
Bạn giúp những người đang sơn lại nhà mua đúng lượng sơn ngay từ lần đầu tiên: không phải đi lại cửa hàng ba lần, cũng không để nhà kho chất đầy những thùng sơn dùng dở. Từ kích thước các phòng và những loại sơn họ đã chọn, bạn tính toán diện tích, số lít (hoặc gallon), cách chọn mua các thùng sơn rẻ nhất và tổng chi phí, đồng thời chỉ ra các bước chuẩn bị làm thay đổi con số tính toán, chẳng hạn như sơn lót đè lên màu tối hoặc tường mới trát.
## Các tệp trong kỹ năng này
- `scripts/paint_estimate.py` - tính toán diện tích, lượng sơn cho mỗi bề mặt, cách kết hợp thùng sơn rẻ nhất cho từng loại sơn, lượng sơn thừa, tổng chi phí và các phát hiện lưu ý (chỉ dùng thư viện chuẩn Python 3)
- `references/measuring-and-coverage.md` - cách đo phòng, kích thước cửa ra vào và cửa sổ tiêu chuẩn, độ phủ trên nhãn so với thực tế, hệ số độ nhám bề mặt, quy tắc về số lớp sơn và sơn lót
- `references/painting-prep-and-buying-guide.md` - thứ tự chuẩn bị, các loại sơn lót, độ bóng bề mặt theo từng phòng, danh sách dụng cụ, mẹo chọn mua, bảo quản sơn thừa
- `templates/paint-shopping-list.md` - danh sách mua sắm và kế hoạch sơn từng phòng để in ra hoặc mang tới cửa hàng
- `examples/two-bedroom-flat-refresh.json` - bốn phòng trong một căn hộ với một phòng ngủ màu tối chuyển sang màu sáng, tường thạch cao mới trong phòng trẻ em và ba loại sơn tính bằng đồng lira Thổ Nhĩ Kỳ
## Quy trình làm việc
### 1. Thu thập thông tin các phòng
Hỏi thông tin cho từng phòng: chiều dài, chiều rộng và chiều cao trần, số lượng và kích thước cửa ra vào cùng cửa sổ (hoặc dùng kích thước tiêu chuẩn trong `references/measuring-and-coverage.md`), những bề mặt cần sơn (tường, trần), màu hiện tại (sáng, trung bình, tối), màu mới, độ nhám bề mặt tường, và liệu có bề mặt nào là tường trát mới hay thạch cao mới không. Hỏi về các loại sơn kèm độ phủ mỗi lớp ghi trên nhãn thùng cùng các kích cỡ thùng và giá bán tại cửa hàng của họ. Sử dụng hệ mét trừ khi họ làm việc với đơn vị feet và gallon.
### 2. Chạy công cụ ước tính
```
python3 scripts/paint_estimate.py examples/two-bedroom-flat-refresh.json
python3 scripts/paint_estimate.py job.json --json
python3 scripts/paint_estimate.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có các phát hiện mức HIGH (số đo không hợp lý, không tìm được cách kết hợp thùng sơn), 2 nghĩa là dữ liệu đầu vào bị lỗi.
### 3. Điều chỉnh kế hoạch
Xử lý dựa trên các phát hiện: thêm sơn lót (và lượng sơn lót riêng, tính theo độ phủ của sơn lót) đối với DARK_TO_LIGHT và NEW_SURFACE, hoặc tăng số lớp sơn lên 3; đổi các kế hoạch sơn một lớp thành hai lớp trừ khi loại sơn đó được bán dưới dạng sơn một lớp phủ lên cùng màu cũ. Chạy lại sau mỗi lần thay đổi. Sử dụng `references/painting-prep-and-buying-guide.md` để tư vấn về độ hoàn thiện bề mặt và dụng cụ.
### 4. Bàn giao kết quả
Điền vào `templates/paint-shopping-list.md` các thùng sơn cần mua, sơn lót, dụng cụ và thứ tự thi công. Nhắc họ mua tất cả các thùng của cùng một màu từ cùng một lô sản xuất (batch), hoặc trộn đều (box) chúng với nhau trước khi sử dụng.
## Kết quả ví dụ
Đối với `examples/two-bedroom-flat-refresh.json`: tường màu trắng ấm cần 20.87 L -> 1 x 15 L + 1 x 7.5 L (3,640 TRY); phòng trẻ em màu xanh xô thơm (sage green) cần 6.87 L -> 1 x 7.5 L (1,450 TRY); trần nhà cần 8.75 L -> 3 x 3.5 L (1,380 TRY); tổng cộng 6,470 TRY trước khi tính sơn lót và dụng cụ. Các phát hiện: phòng ngủ chính màu tối cần sơn lót hoặc sơn lớp thứ ba, tường thạch cao mới ở phòng trẻ em cần một lớp sơn lót loãng (mist coat) hoặc sơn lót chuyên dụng, và việc chỉ sơn một lớp cho trần phòng này sẽ khiến bề mặt bị loang lổ.
## Quy tắc
- Luôn sử dụng độ phủ in trên thùng sơn của người dùng thay vì một con số chung chung bất cứ khi nào họ có thông tin đó.
- Làm tròn lên thành các thùng nguyên; không bao giờ gợi ý kéo giãn lượng sơn bằng cách pha loãng vượt quá hướng dẫn của nhà sản xuất.
- Đề cập đến yếu tố an toàn ở những chỗ quan trọng: thông gió, đặt thang trên mặt phẳng vững chắc, kiểm tra sơn chứa chì trong các ngôi nhà xây trước khoảng năm 1980 trước khi chà nhám.
- Giá cả dựa theo thông tin người dùng đưa; nói rõ rằng tổng chi phí chưa bao gồm sơn lót, dụng cụ và nhân công trừ khi đã được tính gộp vào.
FILE:references/measuring-and-coverage.md
# Đo đạc và Độ phủ
## Đo một căn phòng
- Đo chiều dài và chiều rộng ở sát mặt sàn và đo chiều cao trần tại một hoặc hai vị trí; làm tròn lên đến mức 5 cm (hoặc 2 in) gần nhất.
- Diện tích tường = chu vi x chiều cao = 2 x (chiều dài + chiều rộng) x chiều cao. Đối với phòng hình chữ L, hãy cộng diện tích chiều rộng x chiều cao của từng bức tường lại với nhau.
- Diện tích trần = chiều dài x chiều rộng (cộng riêng các phần trần vát nghiêng).
- Trừ đi cửa ra vào và cửa sổ; giữ nguyên không trừ bất cứ phần nào nhỏ hơn khoảng 0.5 m2 (5 sq ft), vì đằng nào lượng sơn đó cũng dùng để sơn khung viền và đi đường nét cắt góc (cutting-in).
## Kích thước tiêu chuẩn (khi người dùng không biết)
| Hạng mục | Hệ mét (Metric) | Hệ Anh/Mỹ (Imperial) |
|---|---|---|
| Cửa thông phòng | 0.8-0.9 x 2.0-2.05 m (khoảng 1.8 m2) | 30-36 x 80 in (khoảng 20 sq ft) |
| Cửa ban công / hiên nhà | 1.6-1.8 x 2.2 m | 6 x 6.7 ft |
| Cửa sổ | 1.2-1.5 x 1.2-1.4 m | 3 x 4 ft (khoảng 12-15 sq ft) |
| Chiều cao trần | 2.5-2.8 m (căn hộ mới), lên tới 3.5 m (tòa nhà cũ) | 8-9 ft |
## Độ phủ
- Nhãn thùng sơn cho biết độ phủ trên mỗi lớp, thường là 10-14 m2/L đối với sơn nước (emulsion) (khoảng 350-400 sq ft trên mỗi gallon Mỹ). Độ phủ thực tế thường thấp hơn 10-20%; mức dự phòng hao hụt (mặc định 10%) dùng để bù cho lượng sơn bám vào con lăn, khay sơn và việc dặm vá.
- Hệ số độ nhám bề mặt được công cụ ước tính sử dụng: nhẵn mịn (smooth) x1.0, nhám nhẹ (light texture: giấy dán tường dăm gỗ, sần vỏ cam, trát mỏng) x1.1, thô nhám (rough: gạch trần, vữa gai stucco, trần sần bỏng ngô) x1.25.
- Tường mới trát vữa và tường thạch cao trần hút sơn như bọt biển: hãy khóa bề mặt trước bằng một lớp sơn lót loãng (mist coat - sơn nước pha loãng theo tỷ lệ nhà sản xuất cho phép) hoặc sơn lót chuyên dụng cho thạch cao.
## Số lớp sơn
| Tình huống | Kế hoạch |
|---|---|
| Cùng màu hoặc màu tương tự, bề mặt tốt | 2 lớp (1 lớp nếu sơn được bán là loại sơn một lớp và sơn cùng màu cũ) |
| Màu sáng đè lên màu tối | sơn lót chặn vết ố hoặc sơn lót pha màu + 2 lớp sơn phủ, hoặc 3 lớp sơn phủ |
| Màu tối hoặc màu đậm đè lên màu sáng | sơn lót pha ánh xám + 2 lớp sơn phủ; màu đỏ và vàng có thể cần 3 lớp |
| Tường mới trát hoặc thạch cao mới | lớp sơn lót loãng (mist coat) hoặc sơn lót + 2 lớp sơn phủ |
| Trần nhà | 2 lớp sơn trắng trần nhà; 1 lớp trông sẽ bị loang lổ khi nhìn ngược sáng |
## Kiểm tra nhanh bằng cách tính nhẩm
Số lít = diện tích x số lớp x hệ số độ nhám / độ phủ x 1.1. Ví dụ: 35 m2 tường, 2 lớp, 10 m2/L, bề mặt nhẵn -> 7 L trước hao hụt, 7.7 L khi tính thêm 10% hao hụt.
FILE:references/painting-prep-and-buying-guide.md
# Hướng dẫn Chuẩn bị Sơn và Chọn mua
## Thứ tự thi công
1. Dọn dẹp và che chắn: chuyển đồ đạc vào giữa phòng, phủ bạt che sàn, tháo mặt công tắc/ổ cắm (ngắt điện khi làm việc gần ổ cắm).
2. Sửa chữa bề mặt:ek/trám các vết nứt và lỗ thủng, chà nhám cho phẳng, lau sạch bụi; rửa sạch tường bếp bám dầu mỡ bằng xà phòng tẩy rửa chuyên dụng (sugar soap).
3. Sơn lót ở những chỗ cần thiết (vết ố, chỗ trám vá, màu tối, tường mới trát).
4. Sơn trần trước, sau đó đến tường, cuối cùng là phào chỉ và cửa.
5. Dùng cọ quét viền các mép góc (cut in), sau đó dùng con lăn lăn theo từng mảng khi phần viền vẫn còn ướt để tránh để lại vệt nối.
6. Để mỗi lớp sơn khô theo đúng thời gian ghi trên thùng (thường là 2-4 giờ đối với sơn nước) trước khi sơn lớp tiếp theo.
## Sơn lót
| Vấn đề | Loại sơn lót |
|---|---|
| Vết ố nước, khói, bút dạ | sơn lót chặn vết ố (gốc cánh kiến shellac hoặc gốc dầu cho các vết ố nặng) |
| Màu sáng đè lên màu tối | sơn lót đa năng màu trắng hoặc pha màu nhạt |
| Tường trát vữa mới | lớp sơn lót loãng (mist coat) hoặc dung dịch khóa bề mặt vữa |
| Tường thạch cao mới | sơn lót thạch cao (gốc PVA) |
| Lớp sơn cũ bóng | chà nhám nhẹ, sau đó dùng sơn lót tăng độ bám dính |
Độ phủ của sơn lót thường là 8-12 m2/L; hãy ước tính tương tự như sơn phủ với 1 lớp.
## Độ bóng bề mặt theo từng phòng
| Phòng | Độ hoàn thiện (Finish) | Lý do |
|---|---|---|
| Trần nhà | mịn mờ (flat / matte) | che khuyết điểm, không bị chói sáng |
| Phòng khách và phòng ngủ | mịn mờ (matte) hoặc bóng mờ vỏ trứng (eggshell) | vẻ ngoài dịu mắt; dùng loại mịn mờ có thể lau chùi cho gia đình |
| Phòng trẻ em, hành lang | mịn mờ có thể lau chùi hoặc bóng mờ vỏ trứng (eggshell) | chịu được cọ rửa |
| Nhà bếp và phòng tắm | bóng lụa (satin) hoặc sơn chuyên dụng cho bếp/phòng tắm | chống ẩm và chống bám dầu mỡ |
| Cửa và phào chỉ | bóng lụa (satin) hoặc bán bóng (semi-gloss) | bền, dễ lau sạch |
## Dụng cụ cho một căn hộ điển hình
Con lăn (9 in / 23 cm) kèm cán nối dài, khay sơn hoặc xô có lưới gạt, cọ vát góc (5 cm), băng dính giấy che sơn, bạt che sàn, bột trét và dao bả, giấy nhám (độ nhám 120-180 grit), thang chữ A chắc chắn, giẻ lau, găng tay. Mỗi màu sơn dùng một ống bông lăn riêng.
## Mẹo chọn mua
- Giá mỗi lít thường rẻ hơn khi mua thùng lớn; công cụ ước tính sẽ so sánh giá bán thực tế tại cửa hàng.
- Mua tất cả các thùng của cùng một màu cùng lúc (cùng lô sản xuất). Nếu bạn mở nhiều hơn một thùng, hãy đổ chung vào một xô lớn để trộn đều (boxing) giúp màu sơn đồng nhất.
- Giữ lại khoảng 0.5 L mỗi màu để dặm vá về sau.
- Sơn hàm lượng VOC thấp (Low-VOC) giúp giảm mùi; dù vậy vẫn nên mở cửa sổ và nghỉ giải lao định kỳ.
## Xử lý sơn thừa
Đậy chặt nắp, úp ngược thùng bảo quản ở nơi có nhiệt độ trên mức đóng băng, dán nhãn ghi tên phòng và ngày sơn. Không đổ sơn xuống cống rãnh; hãy mang sơn đã khô hoặc sơn lỏng đến điểm thu gom chất thải hoặc tái chế tại địa phương.
FILE:templates/paint-shopping-list.md
# Danh sách Mua sắm Sơn - <nhà / dự án> - <ngày>
**Đơn vị:** <hệ mét / hệ Anh-Mỹ> **Dự phòng hao hụt:** <10%> **Cửa hàng:** <tên>
## Các phòng
| Phòng | Bề mặt | Diện tích | Số lớp | Loại sơn | Ghi chú (sơn lót, sửa chữa) |
|---|---|---|---|---|---|
| | | | | | |
## Sơn cần mua
| Loại sơn (thương hiệu, mã màu, độ bóng) | Lượng cần | Số thùng | Giá |
|---|---|---|---|
| | | | |
## Sơn lót và vật tư
- [ ] Sơn lót: <loại>, <số lượng>
- [ ] Khung con lăn <n>, ống bông lăn <n>, khay sơn, cán nối dài
- [ ] Cọ sơn <kích cỡ>, băng dính che sơn <số cuộn>, bạt phủ <n>
- [ ] Bột trét, giấy nhám, xà phòng rửa tường (sugar soap)
**Tổng chi phí ước tính:** <sơn> + <sơn lót và vật tư> = <tổng cộng>
## Thứ tự thi công
1. Chuẩn bị và sửa chữa: <các phòng>
2. Sơn lót: <các phòng>
3. Trần nhà: <các phòng>
4. Tường: <các phòng>
5. Phào chỉ và cửa: <các phòng>
FILE:examples/two-bedroom-flat-refresh.json
{
"name": "two-bedroom flat refresh before moving in (example prices)",
"units": "metric",
"currency": "TRY",
"waste": 0.1,
"paints": {
"wall matte warm white": {"coverage": 11, "cans": [{"size": 15, "price": 2350}, {"size": 7.5, "price": 1290}, {"size": 2.5, "price": 520}]},
"sage green (kids room)": {"coverage": 10, "cans": [{"size": 7.5, "price": 1450}, {"size": 2.5, "price": 590}, {"size": 0.75, "price": 240}]},
"ceiling white": {"coverage": 12, "cans": [{"size": 15, "price": 1650}, {"size": 3.5, "price": 460}]}
},
"rooms": [
{"name": "living room", "length": 5.4, "width": 4.2, "height": 2.7,
"openings": [{"type": "door", "w": 0.9, "h": 2.05}, {"type": "balcony door", "w": 1.8, "h": 2.2}, {"type": "window", "w": 1.5, "h": 1.4}],
"walls": {"paint": "wall matte warm white", "coats": 2}, "ceiling": {"paint": "ceiling white", "coats": 2}},
{"name": "main bedroom", "length": 4.0, "width": 3.5, "height": 2.7, "previous": "dark", "new": "light",
"openings": [{"type": "door", "w": 0.9, "h": 2.05}, {"type": "window", "w": 1.4, "h": 1.4}],
"walls": {"paint": "wall matte warm white", "coats": 2}, "ceiling": {"paint": "ceiling white", "coats": 2}},
{"name": "kids room", "length": 3.4, "width": 3.0, "height": 2.7, "new_surface": true,
"openings": [{"type": "door", "w": 0.8, "h": 2.05}, {"type": "window", "w": 1.2, "h": 1.4}],
"walls": {"paint": "sage green (kids room)", "coats": 2}, "ceiling": {"paint": "ceiling white", "coats": 1}},
{"name": "hallway", "length": 4.5, "width": 1.2, "height": 2.7, "texture": "light",
"openings": [{"type": "doors", "w": 0.9, "h": 2.05, "count": 5}],
"walls": {"paint": "wall matte warm white", "coats": 2}, "ceiling": {"paint": "ceiling white", "coats": 2}}
]
}
FILE:scripts/paint_estimate.py
#!/usr/bin/env python3
"""Room paint quantity and cost estimator (Python 3 standard library only).
Input: a painting job JSON file:
{"units": "metric", "waste": 0.1, "currency": "TRY",
"paints": {"wall white": {"coverage": 11, "cans": [{"size": 10, "price": 1450}, {"size": 2.5, "price": 450}]}},
"rooms": [{"name": "living room", "length": 5.2, "width": 4.1, "height": 2.7,
"openings": [{"type": "door", "w": 0.9, "h": 2.05}, {"type": "window", "w": 1.6, "h": 1.4, "count": 2}],
"walls": {"paint": "wall white", "coats": 2},
"ceiling": {"paint": "ceiling white", "coats": 2},
"texture": "smooth", "previous": "dark", "new": "light", "new_surface": false}]}
units: metric (m, m2, coverage m2 per litre, cans in litres) or imperial (ft, sq ft,
coverage sq ft per US gallon, cans in gallons). coverage = per coat, from the paint can.
Areas: walls = 2 x (length + width) x height - openings; ceiling = length x width.
texture: smooth (x1.0), light (x1.1, e.g. woodchip, orange peel), rough (x1.25, brick, stucco)
multiplies the paint needed. waste (default 0.10) adds rollers, trays, touch-ups.
Volume per surface = area x coats x texture / coverage x (1 + waste); volumes for the same paint
are added up across rooms, then the cheapest can mix that covers it is chosen (fewer litres
left over on ties).
Findings: OPENINGS_TOO_LARGE (HIGH), NO_CAN_MIX (HIGH), DARK_TO_LIGHT and NEW_SURFACE
(MEDIUM: primer or an extra coat), ONE_COAT, COVERAGE_SUSPECT, ODD_HEIGHT, BIG_LEFTOVER (LOW).
Usage:
python3 paint_estimate.py job.json
python3 paint_estimate.py job.json --json
python3 paint_estimate.py --self-test
Exit code: 0 OK, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import math
import sys
TEXTURE = {"smooth": 1.0, "light": 1.1, "rough": 1.25}
UNITS = {"metric": {"len": "m", "area": "m2", "vol": "L", "cov": (6, 16), "height": (2.2, 4.0)},
"imperial": {"len": "ft", "area": "sq ft", "vol": "gal", "cov": (250, 450), "height": (7.0, 13.0)}}
LIGHTNESS = {"light": 0, "medium": 1, "dark": 2}
class JobError(ValueError):
pass
def num(x, what, minimum=0.0, strict=True):
if isinstance(x, bool) or not isinstance(x, (int, float)) or (x <= minimum if strict else x < minimum):
raise JobError(f"{what} must be a number {'>' if strict else '>='} {minimum:g}")
return float(x)
def best_mix(need, cans):
"""Cheapest combination of cans with total size >= need; ties -> less leftover, fewer cans."""
cans = sorted(cans, key=lambda c: -c["size"])
best = None
def rec(i, vol, cost, picked):
nonlocal best
if vol >= need - 1e-9:
key = (round(cost, 6), round(vol - need, 6), sum(picked))
if best is None or key < best[0]:
best = (key, list(picked), vol, cost)
return
if i == len(cans) or (best is not None and cost > best[0][0] + 1e-9):
return
c = cans[i]
top = math.ceil((need - vol) / c["size"] - 1e-9)
for k in ([top] if i == len(cans) - 1 else range(top, -1, -1)):
picked.append(k)
rec(i + 1, vol + k * c["size"], cost + k * c["price"], picked)
picked.pop()
if need <= 0:
return {"cans": [], "volume": 0.0, "cost": 0.0, "leftover": 0.0}
rec(0, 0.0, 0.0, [])
if best is None:
return None
_, picked, vol, cost = best
return {"cans": [{"size": c["size"], "count": k, "price": c["price"]} for c, k in zip(cans, picked) if k],
"volume": vol, "cost": cost, "leftover": vol - need}
def estimate(job):
units = str(job.get("units", "metric")).lower()
if units not in UNITS:
raise JobError("units must be metric or imperial")
U = UNITS[units]
waste = num(job.get("waste", 0.1), "waste", 0, strict=False)
paints = job.get("paints") or {}
if not paints:
raise JobError("paints is empty")
for pname, p in paints.items():
num(p.get("coverage"), f"paint {pname!r} coverage")
if not p.get("cans"):
raise JobError(f"paint {pname!r} has no cans")
for c in p["cans"]:
num(c.get("size"), f"paint {pname!r} can size")
num(c.get("price", 0), f"paint {pname!r} can price", 0, strict=False)
rooms = job.get("rooms") or []
if not rooms:
raise JobError("rooms is empty")
findings, lines, need = [], [], {}
def f(sev, code, where, msg):
findings.append({"severity": sev, "code": code, "where": where, "message": msg})
for r in rooms:
name = r.get("name") or "room"
L, W, H = (num(r.get(k), f"{name}: {k}") for k in ("length", "width", "height"))
if not U["height"][0] <= H <= U["height"][1]:
f("LOW", "ODD_HEIGHT", name, f"height {H:g} {U['len']} is unusual; check the unit and the measurement")
tex = str(r.get("texture", "smooth")).lower()
if tex not in TEXTURE:
raise JobError(f"{name}: texture must be smooth, light or rough")
openings = 0.0
for o in r.get("openings") or []:
cnt = o.get("count", 1)
if not isinstance(cnt, int) or isinstance(cnt, bool) or cnt < 0:
raise JobError(f"{name}: opening count must be a whole number >= 0")
openings += num(o.get("w"), f"{name}: opening w") * num(o.get("h"), f"{name}: opening h") * cnt
gross = 2 * (L + W) * H
surfaces = {"walls": gross - openings, "ceiling": L * W}
if openings >= gross * 0.6:
f("HIGH", "OPENINGS_TOO_LARGE", name, f"openings {openings:.1f} {U['area']} are {openings / gross:.0%} of the walls; check the measurements")
surfaces["walls"] = max(surfaces["walls"], 0.0)
prev, new = r.get("previous"), r.get("new")
for sname in ("walls", "ceiling"):
s = r.get(sname)
if not s:
continue
pname = s.get("paint")
if pname not in paints:
raise JobError(f"{name} {sname}: unknown paint {pname!r}")
coats = s.get("coats", 2)
if not isinstance(coats, int) or isinstance(coats, bool) or coats < 1:
raise JobError(f"{name} {sname}: coats must be a whole number >= 1")
cov = float(paints[pname]["coverage"])
primer = bool(s.get("primer"))
if sname == "walls" and prev in LIGHTNESS and new in LIGHTNESS and LIGHTNESS[prev] - LIGHTNESS[new] >= 2 and coats < 3 and not primer:
f("MEDIUM", "DARK_TO_LIGHT", name, "dark to light colour change: add a stain-blocking primer or a third coat")
if r.get("new_surface") and not primer and coats < 3:
f("MEDIUM", "NEW_SURFACE", name, f"new plaster or drywall on the {sname}: seal with a mist coat or primer first (it soaks up paint)")
if coats == 1:
f("LOW", "ONE_COAT", f"{name} {sname}", "one coat usually looks patchy; plan two")
vol = surfaces[sname] * coats * TEXTURE[tex] / cov * (1 + waste)
need[pname] = need.get(pname, 0.0) + vol
lines.append({"room": name, "surface": sname, "area": round(surfaces[sname], 2), "coats": coats,
"paint": pname, "texture": tex, "volume": round(vol, 2)})
for pname, p in paints.items():
lo, hi = U["cov"]
if pname in need and not lo <= p["coverage"] <= hi:
f("LOW", "COVERAGE_SUSPECT", pname, f"coverage {p['coverage']:g} {U['area']}/{U['vol']} per coat is outside the usual {lo}-{hi}; check the label")
buys, total = [], 0.0
for pname in sorted(need):
mix = best_mix(round(need[pname], 4), paints[pname]["cans"])
if mix is None:
f("HIGH", "NO_CAN_MIX", pname, "no can combination found")
continue
total += mix["cost"]
buys.append(dict(mix, paint=pname, need=round(need[pname], 2)))
if mix["volume"] and mix["leftover"] > 0.3 * mix["volume"] and mix["leftover"] > 1:
f("LOW", "BIG_LEFTOVER", pname, f"{mix['leftover']:.1f} {U['vol']} left over; keep it for touch-ups or check smaller can prices")
rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
findings.sort(key=lambda x: rank[x["severity"]])
return {"name": job.get("name", "painting job"), "units": units, "currency": job.get("currency", ""),
"waste": waste, "lines": lines, "buy": buys, "total_cost": round(total, 2), "findings": findings}
def g(x):
return f"{x:.2f}".rstrip("0").rstrip(".")
def report(res):
U = UNITS[res["units"]]
cur = res["currency"]
out = [f"# Paint estimate: {res['name']}", "",
f"Units: {res['units']} ({U['area']}, {U['vol']}); waste allowance {res['waste']:.0%}", "",
f"| Room | Surface | Area ({U['area']}) | Coats | Paint | Texture | Paint needed ({U['vol']}) |", "|---|---|---|---|---|---|---|"]
for l in res["lines"]:
out.append(f"| {l['room']} | {l['surface']} | {g(l['area'])} | {l['coats']} | {l['paint']} | {l['texture']} | {g(l['volume'])} |")
out += ["", "## Shopping list", f"| Paint | Needed ({U['vol']}) | Buy | Bought ({U['vol']}) | Left over | Cost |", "|---|---|---|---|---|---|"]
for b in res["buy"]:
mix = " + ".join(f"{c['count']} x {g(c['size'])} {U['vol']}" for c in b["cans"])
out.append(f"| {b['paint']} | {g(b['need'])} | {mix} | {g(b['volume'])} | {g(b['leftover'])} | {g(b['cost'])} {cur} |")
out += ["", f"**Total paint cost: {g(res['total_cost'])} {cur}** (primer, tape, rollers and fillers not included)", "", "## Findings"]
if not res["findings"]:
out.append("- none")
for x in res["findings"]:
out.append(f"- **{x['severity']} {x['code']}** ({x['where']}): {x['message']}")
return "\n".join(out) + "\n"
def self_test():
cases = []
def ok(name, cond):
cases.append(bool(cond))
print(("PASS " if cond else "FAIL ") + name)
cans = [{"size": 10, "price": 100}, {"size": 2.5, "price": 30}]
m = best_mix(11, cans)
ok("11 L -> 10 + 2.5 for 130", m["cost"] == 130 and m["volume"] == 12.5)
m = best_mix(8, cans)
ok("8 L -> one 10 L (100 < 4 x 30)", m["cans"] == [{"size": 10, "count": 1, "price": 100}])
m = best_mix(7, cans)
ok("7 L -> 3 x 2.5 for 90", m["cost"] == 90 and m["leftover"] == 0.5)
ok("tie prefers less leftover", best_mix(2, [{"size": 4, "price": 10}, {"size": 2, "price": 10}])["volume"] == 2)
ok("zero need buys nothing", best_mix(0, cans)["cans"] == [])
base = {"waste": 0, "paints": {"w": {"coverage": 10, "cans": cans}},
"rooms": [{"name": "r", "length": 4, "width": 3, "height": 2.5, "walls": {"paint": "w", "coats": 2}}]}
r = estimate(base)
ok("wall area 2x(4+3)x2.5 = 35", r["lines"][0]["area"] == 35)
ok("35 m2 x 2 coats / 10 = 7 L", r["lines"][0]["volume"] == 7)
ok("clean job has no findings", r["findings"] == [] and r["total_cost"] == 90)
j = json.loads(json.dumps(base)); j["rooms"][0]["openings"] = [{"w": 0.9, "h": 2, "count": 1}, {"w": 1.2, "h": 1.25, "count": 2}]
ok("openings subtracted (35 - 1.8 - 3 = 30.2)", estimate(j)["lines"][0]["area"] == 30.2)
j = json.loads(json.dumps(base)); j["rooms"][0]["ceiling"] = {"paint": "w", "coats": 1}
r = estimate(j)
ok("ceiling area 12 and one-coat flagged", r["lines"][1]["area"] == 12 and any(x["code"] == "ONE_COAT" for x in r["findings"]))
ok("same paint summed across surfaces", r["buy"][0]["need"] == 8.2)
j = json.loads(json.dumps(base)); j["waste"] = 0.1; j["rooms"][0]["texture"] = "rough"
ok("rough + 10% waste: 7 x 1.25 x 1.1", estimate(j)["lines"][0]["volume"] == 9.62)
j = json.loads(json.dumps(base)); j["rooms"][0].update(previous="dark", new="light")
ok("dark to light flagged", any(x["code"] == "DARK_TO_LIGHT" for x in estimate(j)["findings"]))
j["rooms"][0]["walls"]["primer"] = True
ok("primer clears dark to light", not any(x["code"] == "DARK_TO_LIGHT" for x in estimate(j)["findings"]))
j = json.loads(json.dumps(base)); j["rooms"][0]["new_surface"] = True
ok("new surface flagged", any(x["code"] == "NEW_SURFACE" for x in estimate(j)["findings"]))
j = json.loads(json.dumps(base)); j["rooms"][0]["openings"] = [{"w": 3, "h": 2.5, "count": 4}]
ok("openings too large flagged", any(x["code"] == "OPENINGS_TOO_LARGE" for x in estimate(j)["findings"]))
j = json.loads(json.dumps(base)); j["paints"]["w"]["coverage"] = 30
ok("coverage suspect flagged", any(x["code"] == "COVERAGE_SUSPECT" for x in estimate(j)["findings"]))
j = json.loads(json.dumps(base)); j["rooms"][0]["height"] = 8
ok("metric height 8 m flagged", any(x["code"] == "ODD_HEIGHT" for x in estimate(j)["findings"]))
imp = {"units": "imperial", "waste": 0, "paints": {"w": {"coverage": 350, "cans": [{"size": 1, "price": 40}, {"size": 5, "price": 170}]}},
"rooms": [{"name": "r", "length": 12, "width": 10, "height": 8, "walls": {"paint": "w", "coats": 2}}]}
r = estimate(imp)
ok("imperial 352 sq ft x2 / 350 -> 2.01 gal -> 3 x 1 gal", r["lines"][0]["area"] == 352 and r["buy"][0]["cans"] == [{"size": 1, "count": 3, "price": 40}])
j = json.loads(json.dumps(base)); j["paints"]["w"]["cans"] = [{"size": 1, "price": 5}]; j["rooms"][0]["length"] = 40
ok("big leftover not flagged for exact fit", not any(x["code"] == "BIG_LEFTOVER" for x in estimate(j)["findings"]))
j = json.loads(json.dumps(base)); j["paints"]["w"]["cans"] = [{"size": 20, "price": 150}]
ok("big leftover flagged (20 L for 7 L)", any(x["code"] == "BIG_LEFTOVER" for x in estimate(j)["findings"]))
for mut, label in ((lambda j: j.update(units="cubits"), "bad units"), (lambda j: j.update(paints={}), "no paints"),
(lambda j: j["rooms"][0].update(height=0), "zero height"),
(lambda j: j["rooms"][0]["walls"].update(paint="x"), "unknown paint"),
(lambda j: j["rooms"][0]["walls"].update(coats=1.5), "fractional coats"),
(lambda j: j["rooms"][0].update(texture="glitter"), "bad texture"),
(lambda j: j.update(rooms=[]), "no rooms")):
j = json.loads(json.dumps(base))
mut(j)
try:
estimate(j)
ok(label + " rejected", False)
except JobError:
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="Estimate paint quantity, cans to buy and cost for rooms.")
ap.add_argument("file", nargs="?", help="painting job JSON")
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 job JSON file or --self-test")
try:
res = estimate(json.load(open(a.file, encoding="utf-8")))
except (OSError, json.JSONDecodeError, JobError, AttributeError, TypeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else report(res), end="\n" if a.json else "")
return 1 if any(x["severity"] == "HIGH" for x in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())