# Estimate annual heating energy savings from insulating a 100% uninsulated,
# axis-aligned rectangular house footprint drawn in a DXF.
# Gaps along the outer rectangle edges are treated as windows.
# - Detect the bounding rectangle from all LINE / LWPOLYLINE segments.
# - For each of the 4 sides (x=min, x=max, y=min, y=max), build 1D coverage
# intervals from segments that lie on that side (within tolerance).
# - Uncovered length on that side = window length on that side.
# Annual kWh ≈ HDD(°C·day) * 24(h/day) * (UA_before - UA_after) / 1000
# with UA = Σ(U_i * A_i).
# python insulation_savings_dxf.py --dxf house.dxf --height 2.7 --hdd 2200 --price 0.22 \
# --window-height 1.2 --insulate-walls --insulate-roof --units-scale 1.0
# Requirements: pip install ezdxf numpy
from dataclasses import dataclass
from typing import List, Tuple
wall_unins: float = 1.7 # W/m²·K
wall_ins: float = 0.30 # W/m²·K
roof_unins: float = 2.0 # W/m²·K (ceiling uninsulated baseline)
roof_ins: float = 0.20 # W/m²·K
window: float = 2.7 # W/m²·K (kept constant unless changed)
def merge_intervals(intervals: List[Tuple[float, float]], tol: float = 0.0) -> List[Tuple[float,float]]:
"""Merge [a,b] intervals on a line."""
ints = sorted([(min(a,b), max(a,b)) for a,b in intervals])
merged[-1] = (la, max(lb, b))
def total_gap_length(side_min: float, side_max: float, covered: List[Tuple[float,float]]) -> float:
"""Length on [side_min, side_max] not covered by intervals."""
side_min, side_max = side_max, side_min
merged = merge_intervals(covered, tol=0.0)
covered_len = sum(max(0.0, min(b, side_max) - max(a, side_min)) for a,b in merged)
total_len = side_max - side_min
return max(0.0, total_len - covered_len)
def extract_segments(doc, units_scale=1.0, layer_filter=None):
Return list of segments [(x1,y1,x2,y2)] from LINE and LWPOLYLINE.
units_scale converts DXF units to meters (e.g., mm -> m = 0.001).
Optional layer_filter: set of allowed layer names.
return (layer_filter is None) or (entity.dxf.layer in layer_filter)
for e in msp.query("LINE"):
if not on_layer(e): continue
x1,y1 = e.dxf.start.x * units_scale, e.dxf.start.y * units_scale
x2,y2 = e.dxf.end.x * units_scale, e.dxf.end.y * units_scale
segs.append((x1,y1,x2,y2))
for e in msp.query("LWPOLYLINE"):
if not on_layer(e): continue
pts = [(p[0]*units_scale, p[1]*units_scale) for p in e.get_points("xy")]
if e.closed and len(pts) >= 2:
for (x1,y1),(x2,y2) in zip(pts[:-1], pts[1:]):
segs.append((x1,y1,x2,y2))
def project_to_side_intervals(segs, side, coord, span_min, span_max, tol):
Build coverage intervals along a side.
side: 'left','right' -> vertical line x=coord, span is y; 'bottom','top' -> horizontal line y=coord, span is x.
For each segment nearly coincident with the side (distance < tol), add its projected [t0,t1] on the span axis.
if side in ("left","right"):
# vertical side x = coord; span along y
# if nearly vertical AND within tol of side line
if abs(x1 - x2) < tol and abs((x1 + x2)/2 - coord) < tol:
a = max(a, span_min); b = min(b, span_max)
# if segment crosses the side line and is nearly on it (rare for crooked lines) -> ignore to keep simple
# horizontal side y = coord; span along x
if abs(y1 - y2) < tol and abs((y1 + y2)/2 - coord) < tol:
a = max(a, span_min); b = min(b, span_max)
ap = argparse.ArgumentParser(description="Estimate annual heating energy savings from a DXF outline with window gaps.")
ap.add_argument("--dxf", required=True, help="Path to the DXF file (axis-aligned rectangle outline; gaps = windows).")
ap.add_argument("--units-scale", type=float, default=1.0, help="Scale factor to convert DXF units to meters (e.g., mm->m: 0.001).")
ap.add_argument("--height", type=float, default=2.5, help="Uniform wall height (m).")
ap.add_argument("--window-height", type=float, default=1.2, help="Assumed window height (m).")
ap.add_argument("--hdd", type=float, default=2000, help="Annual heating degree days (°C·day).")
ap.add_argument("--price", type=float, default=0.20, help="Energy price (currency/kWh).")
ap.add_argument("--insulate-walls", action="store_true", help="Insulate walls.")
ap.add_argument("--insulate-roof", action="store_true", help="Insulate roof/ceiling.")
ap.add_argument("--u-wall-unins", type=float, default=UValues.wall_unins, help="U-value uninsulated wall (W/m²K).")
ap.add_argument("--u-wall-ins", type=float, default=UValues.wall_ins, help="U-value insulated wall (W/m²K).")
ap.add_argument("--u-roof-unins", type=float, default=UValues.roof_unins, help="U-value uninsulated roof/ceiling (W/m²K).")
ap.add_argument("--u-roof-ins", type=float, default=UValues.roof_ins, help="U-value insulated roof/ceiling (W/m²K).")
ap.add_argument("--u-window", type=float, default=UValues.window, help="U-value windows (W/m²K).")
ap.add_argument("--tol", type=float, default=0.005, help="Geometric tolerance (m) to detect lines on each side (default 5 mm).")
ap.add_argument("--layer", action="append", help="Optional: restrict to these layer names (can repeat).")
doc = ezdxf.readfile(args.dxf)
layer_filter = set(args.layer) if args.layer else None
segs = extract_segments(doc, units_scale=args.units_scale, layer_filter=layer_filter)
raise SystemExit("No LINE/LWPOLYLINE geometry found (check layer filter or file).")
# Bounding rectangle from all endpoints
pts = np.array([(x1,y1) for x1,y1,_,_ in segs] + [(x2,y2) for _,_,x2,y2 in segs], dtype=float)
minx, miny = pts.min(axis=0)
maxx, maxy = pts.max(axis=0)
if width <= 0 or depth <= 0:
raise SystemExit("Degenerate bounding box—are all points collinear?")
# Build coverage on each side
left_intervals = project_to_side_intervals(segs, "left", minx, miny, maxy, args.tol)
right_intervals = project_to_side_intervals(segs, "right", maxx, miny, maxy, args.tol)
bottom_intervals = project_to_side_intervals(segs, "bottom", miny, minx, maxx, args.tol)
top_intervals = project_to_side_intervals(segs, "top", maxy, minx, maxx, args.tol)
gap_left = total_gap_length(miny, maxy, left_intervals)
gap_right = total_gap_length(miny, maxy, right_intervals)
gap_bottom = total_gap_length(minx, maxx, bottom_intervals)
gap_top = total_gap_length(minx, maxx, top_intervals)
window_gap_total = gap_left + gap_right + gap_bottom + gap_top # meters of opening along edges
perimeter = 2.0 * (width + depth)
floor_area = width * depth
roof_area = floor_area # flat roof assumption
window_area = window_gap_total * args.window_height
wall_area = max(perimeter * args.height - window_area, 0.0)
wall_unins=args.u_wall_unins,
wall_ins=args.u_wall_ins,
roof_unins=args.u_roof_unins,
roof_ins=args.u_roof_ins,
UA_before = u.wall_unins * wall_area + u.roof_unins * roof_area + u.window * window_area
UA_after = (u.wall_ins if args.insulate_walls else u.wall_unins) * wall_area \
+ (u.roof_ins if args.insulate_roof else u.roof_unins) * roof_area \
annual_kWh_before = args.hdd * 24.0 * UA_before / 1000.0
annual_kWh_after = args.hdd * 24.0 * UA_after / 1000.0
savings_kWh = max(annual_kWh_before - annual_kWh_after, 0.0)
savings_cost = savings_kWh * args.price
print("=== Geometry from DXF ===")
print(f"DXF units scale to meters: {args.units_scale}")
print(f"Bounding rectangle (m): width={width:.3f} depth={depth:.3f}")
print(f"Perimeter (m): {perimeter:.3f}")
print(f"Gaps (m): left={gap_left:.3f}, right={gap_right:.3f}, bottom={gap_bottom:.3f}, top={gap_top:.3f}")
print(f"Total window gap length (m): {window_gap_total:.3f}")
print(f"Assumed window height (m): {args.window_height:.2f}")
print(f"Window area (m²): {window_area:.2f}")
print(f"Wall height (m): {args.height:.2f}")
print(f"Wall area minus windows (m²): {wall_area:.2f}")
print(f"Roof area (m²): {roof_area:.2f}")
print("\n=== Thermal model ===")
print(f"HDD (°C·day): {args.hdd:.0f}")
print(f"U-values (W/m²K): wall unins={u.wall_unins}, wall ins={u.wall_ins}, roof unins={u.roof_unins}, roof ins={u.roof_ins}, window={u.window}")
print(f"UA BEFORE (W/K): {UA_before:.1f}")
print(f"UA AFTER (W/K): {UA_after:.1f}")
print("\n=== Results ===")
print(f"Annual energy BEFORE: {annual_kWh_before:,.0f} kWh")
print(f"Annual energy AFTER: {annual_kWh_after:,.0f} kWh")
print(f"Annual SAVINGS: {savings_kWh:,.0f} kWh")
print(f"At {args.price:.2f} per kWh → SAVINGS ≈ {savings_cost:,.0f}")
print("- Outline must be an axis-aligned rectangle. Any missing segment along an edge is treated as a window opening.")
print("- If your DXF uses mm, pass --units-scale 0.001. If cm, 0.01. If already meters, leave as 1.0.")
print("- For messy drawings, use --layer to restrict which layer(s) contain the outline, e.g., --layer OUTLINE --layer EXTERIOR.")
print("- Windows are modeled with a fixed height; change --window-height to your typical head/jamb dimensions.")
print("- The ceiling/roof is assumed initially uninsulated; enable --insulate-roof to model adding insulation there.")
print("- This is a steady-state HDD model (no thermal mass/infiltration). You can tune U-values to match local practice.")
if __name__ == "__main__":