#!/usr/bin/env python3 """ABLAQ Studios — Radiative Environments, release 1.0.0. Institutional research software. Scientific lead: Osama Khaled. Python 3.10+, standard library only. No observations or weather files used. Run: python3 -B radiative-study.v1.py --output results License: CC0-1.0. Methods and references: https://ablaqstudios.com/data """ import argparse, csv, hashlib, json, math, pathlib, sys from datetime import date from collections import defaultdict from time import perf_counter VERSION = "1.0.0" PI = math.pi RATIOS = [1, 1.5, 2, 3, 4] HEIGHTS = [2.5, 5, 7.5] BEARINGS = list(range(0, 91, 15)) LATITUDES = [29, 32, 35] MONTH_LENGTHS = [31,28,31,30,31,30,31,31,30,31,30,31] def geometry(ratio, height): return math.sqrt(100/ratio), math.sqrt(100*ratio), height def declination(day): # NOAA fractional-year approximation, evaluated at noon for each non-leap day. g = 2*PI*(day-1)/365 return (.006918-.399912*math.cos(g)+.070257*math.sin(g) -.006758*math.cos(2*g)+.000907*math.sin(2*g) -.002697*math.cos(3*g)+.00148*math.sin(3*g)) def sun(latitude, decl, hour_angle): p = math.radians(latitude) e = -math.cos(decl)*math.sin(hour_angle) n = math.cos(p)*math.sin(decl)-math.sin(p)*math.cos(decl)*math.cos(hour_angle) z = math.sin(p)*math.sin(decl)+math.cos(p)*math.cos(decl)*math.cos(hour_angle) return e,n,z def transmission(w,l,h,e,n,z,bearing): if z <= 0: return 0. b = math.radians(bearing) dx=h*(e*math.cos(b)-n*math.sin(b))/z dy=h*(n*math.cos(b)+e*math.sin(b))/z return max(0.,1-abs(dx)/w)*max(0.,1-abs(dy)/l) def sky_exact(w,l,h): x,y=w/h,l/h return 2/(PI*x*y)*( .5*math.log((1+x*x)*(1+y*y)/(1+x*x+y*y)) +x*math.sqrt(1+y*y)*math.atan(x/math.sqrt(1+y*y)) +y*math.sqrt(1+x*x)*math.atan(y/math.sqrt(1+x*x)) -x*math.atan(x)-y*math.atan(y)) def sky_angular(w,l,h,n=240): # Isotropic sky, cosine-weighted hemispherical integral. u=sin(altitude)^2. # Symmetry permits one azimuth quadrant. Midpoint product quadrature. total=0. angles=[(math.cos((i+.5)*PI/(2*n)),math.sin((i+.5)*PI/(2*n))) for i in range(n)] for j in range(n): u=(j+.5)/n distance=h*math.sqrt((1-u)/u) total+=sum(max(0,1-distance*c/w)*max(0,1-distance*s/l) for c,s in angles) return total/(n*n) def sky_kernel(w,l,h,n=240): # Floor-to-opening area kernel, reduced using differences of coordinates. total=0. for i in range(n): x=(i+.5)*w/n for j in range(n): y=(j+.5)*l/n total+=(w-x)*(l-y)*h*h/(h*h+x*x+y*y)**2 return total*4/(PI*n*n) def solar_samples(latitude, step_minutes=10): groups=[[] for _ in range(12)] day=0 for month,days in enumerate(MONTH_LENGTHS): for _ in range(days): day+=1; d=declination(day) for i in range(round(1440/step_minutes)): hour=(i+.5)*step_minutes/60 e,n,z=sun(latitude,d,math.radians(15*(hour-12))) if z>0: groups[month].append((e,n,z,step_minutes/60)) return groups def integrate(groups,w,l,h,b): cb,sb=math.cos(math.radians(b)),math.sin(math.radians(b)) rows=[] for samples in groups: beam=openbeam=hours=0. for e,n,z,dt in samples: dx=h*(e*cb-n*sb)/z;dy=h*(n*cb+e*sb)/z f=max(0.,1-abs(dx)/w)*max(0.,1-abs(dy)/l) beam+=f*z*dt;openbeam+=z*dt;hours+=f*dt rows.append((beam/openbeam,beam,openbeam,hours)) return rows def write_csv(path,rows): with path.open("w",newline="",encoding="utf-8") as f: w=csv.DictWriter(f,fieldnames=list(rows[0]),lineterminator="\n");w.writeheader() for row in rows: w.writerow({k:format(v,'.10g') if isinstance(v,float) else v for k,v in row.items()}) def svg_plot(path,title,description,series,xlabel,ylabel,xticks,yticks): # Publication figure, independent SVG artifact; all values supplied by calculations. from html import escape W,H=960,560;left,right,top,bottom=86,36,72,110 pw,ph=W-left-right,H-top-bottom xmin,xmax=xticks[0][0],xticks[-1][0];ymin,ymax=yticks[0][0],yticks[-1][0] xp=lambda x:left+(x-xmin)/(xmax-xmin)*pw yp=lambda y:top+ph-(y-ymin)/(ymax-ymin)*ph a=[f'{escape(title)}{escape(description)}{escape(title)}'] for y,label in yticks: a+=[f'{escape(str(label))}'] for x,label in xticks: a+=[f'{escape(str(label))}'] a+=[f'{escape(xlabel)}{escape(ylabel)}'] for i,(name,color,points) in enumerate(series): a+=[f''] lx=left+i*260 a+=[f'{escape(name)}'] a+=[''];path.write_text(''.join(a)+'\n') def main(out): out.mkdir(parents=True,exist_ok=False) monthly=[];annual=[];checks=[];samples={} for lat in LATITUDES: samples[lat]=solar_samples(lat) for r in RATIOS: for h in HEIGHTS: w,l,_=geometry(r,h);sv=sky_exact(w,l,h) for b in BEARINGS: rows=integrate(samples[lat],w,l,h,b) common=dict(latitude_deg_n=lat,aspect_ratio=r,wall_height_m=h,long_axis_bearing_deg=b) for m,(t,beam,ob,hrs) in enumerate(rows,1): monthly.append(dict(**common,month=m,beam_transmission=t,transmitted_cosine_hours=beam,open_cosine_hours=ob,sunlit_area_hours=hrs)) total=sum(x[1] for x in rows)/sum(x[2] for x in rows) winter=sum(rows[i][1] for i in (11,0,1))/sum(rows[i][2] for i in (11,0,1)) summer=sum(rows[i][1] for i in (5,6,7))/sum(rows[i][2] for i in (5,6,7)) annual.append(dict(**common,annual_beam=total,winter_beam=winter,summer_beam=summer,sky_view_factor=sv)) print(json.dumps({"phase":"solar","annual_cases":len(annual),"monthly_rows":len(monthly)}),flush=True) sky=[];maxangular=maxkernel=0. for r in [1,1.5,2,3,4,6,8]: for h in [1,2.5,5,7.5,10]: w,l,_=geometry(r,h);v=sky_exact(w,l,h) sky.append(dict(aspect_ratio=r,wall_height_m=h,width_m=w,length_m=l,area_m2=100,area_averaged_sky_view=v)) for r,h in [(1,1),(1,5),(4,2.5),(4,7.5),(8,10)]: w,l,_=geometry(r,h);v=sky_exact(w,l,h) for n in [60,120,240]: angular=sky_angular(w,l,h,n);kernel=sky_kernel(w,l,h,n) checks.append(dict(kind="sky_quadrature",aspect_ratio=r,height=h,resolution=n,exact=v,angular=angular,kernel=kernel,angular_error=abs(angular-v),kernel_error=abs(kernel-v))) if n==240:maxangular=max(maxangular,abs(angular-v));maxkernel=max(maxkernel,abs(kernel-v)) # Convergence for every latitude, aspect extremes, and both orientation extremes at h=5. maxsolar=0. for lat in LATITUDES: fine=solar_samples(lat,5) for r in [1,4]: for b in [0,90]: w,l,h=geometry(r,5) coarse=integrate(samples[lat],w,l,h,b);ref=integrate(fine,w,l,h,b) err=max(abs(a[0]-c[0]) for a,c in zip(coarse,ref)) maxsolar=max(maxsolar,err) checks.append(dict(kind="temporal_convergence",latitude=lat,aspect_ratio=r,bearing=b,max_monthly_absolute_difference=err)) # Positive-horizontal solar integral has a closed day-level answer at fixed declination. integral_error=0. for lat in LATITUDES: p=math.radians(lat) for day in [1,80,172,266,355]: d=declination(day);a=math.sin(p)*math.sin(d);b=math.cos(p)*math.cos(d) ws=math.acos(-a/b) exact=24/PI*(a*ws+b*math.sin(ws)) num=sum(max(0,sun(lat,d,math.radians(15*((i+.5)/6-12)))[2])/6 for i in range(144)) integral_error=max(integral_error,abs(num-exact)) assert maxangular<0.0002, maxangular assert maxkernel<0.0005, maxkernel assert maxsolar<0.001, maxsolar assert integral_error<0.002, integral_error assert all(0<=r[k]<=1 for r in annual for k in ("annual_beam","winter_beam","summer_beam","sky_view_factor")) assert abs(sky_exact(10,10,10)-0.19982489569838746)<1e-12 assert abs(sky_exact(5,20,5)-sky_exact(20,5,5))<1e-12 assert transmission(10,10,0,1,1,1,0)==1 assert transmission(10,10,5,0,0,1,0)==1 # Square orientation invariance under quarter turn. for lat in LATITUDES: a=[x for x in annual if x['latitude_deg_n']==lat and x['aspect_ratio']==1 and x['wall_height_m']==5] assert abs(a[0]['annual_beam']-a[-1]['annual_beam'])<1e-12 mix=[] for a in annual: if a['latitude_deg_n']!=32: continue for j in range(11): d=j/10 mix.append(dict(**{k:a[k] for k in ('aspect_ratio','wall_height_m','long_axis_bearing_deg')},diffuse_fraction=d, annual_transmission=(1-d)*a['annual_beam']+d*a['sky_view_factor'], winter_transmission=(1-d)*a['winter_beam']+d*a['sky_view_factor'], summer_transmission=(1-d)*a['summer_beam']+d*a['sky_view_factor'])) get=lambda ratio,bearing: next(x for x in annual if x['latitude_deg_n']==32 and x['aspect_ratio']==ratio and x['wall_height_m']==5 and x['long_axis_bearing_deg']==bearing) square=get(1,0);longns=get(4,0);longew=get(4,90) # Enumerate the non-dominated winter-admission / summer-rejection set, with fixed h=5. frontiers={} for j in [0,5,10]: rows=[a for a in mix if a["wall_height_m"]==5 and a["diffuse_fraction"]==j/10] frontiers[str(j/10)]=[a for a in rows if not any((b["winter_transmission"]>=a["winter_transmission"]-1e-12 and b["summer_transmission"]<=a["summer_transmission"]+1e-12 and (b["winter_transmission"]>a["winter_transmission"]+1e-12 or b["summer_transmission"]