-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
1541 lines (1413 loc) · 72.3 KB
/
Copy pathserver.py
File metadata and controls
1541 lines (1413 loc) · 72.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""FastMCP server exposing the EPWForge tools.
v0.2.0 — 4-tool consolidation:
find_station (no auth) Search the GuzzStations catalog
analyze_weather (no auth) Stats from an EPW URL or synthesized config
chart_weather (no auth) SVG chart from an EPW URL or synthesized config
generate_weather_file (auth) Delivers EPW/DDY; charges credits
URL-mode for the 3 read tools runs entirely locally (download + parse +
chart). Config-mode (synthesized weather) routes through the hosted MCP
at https://epwforge.com/api/mcp so the morphing pipeline executes on
EPWForge infrastructure and never returns the EPW content to the caller —
anon-safe by construction. generate_weather_file requires an
EPWFORGE_API_KEY because it delivers actual EPW/DDY files and charges
credits.
Set EPWFORGE_API_KEY in env (or in your MCP client config) to enable
generate_weather_file. Read tools work without a key.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import os
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Any, Literal
import httpx
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from . import __version__
from .charts import compare_scenarios_svg, diurnal_profile_svg
from .client import EPWForgeClient, EPWForgeError, download_text, write_epw_base64
from .epw_parser import (
EPWFile,
c_to_f,
daily_means_by_date,
design_conditions_F,
format_md,
m_to_ft,
monthly_means,
parse_epw,
percentile,
)
# ── TMY vintage choices (must match lib/tmy-period.ts on the platform side) ──
TMY_PERIOD_CHOICES = ("full", "2011-2025", "2009-2023", "2007-2021", "2004-2018")
DEFAULT_TMY_PERIOD = "2011-2025"
TmyPeriod = Literal["full", "2011-2025", "2009-2023", "2007-2021", "2004-2018"]
VALID_EVENTS = ("heatwave", "coldsnap", "hothumid", "coldwindy")
# ── MCP Apps (SEP-1865) interactive UI resources ─────────────────────────────
# Tools that reference a UI resource via their `meta.ui.resourceUri` render
# the linked HTML inline in supporting hosts (Claude Desktop, ChatGPT, VS
# Code, Goose). Clients without MCP Apps support fall back to the plain-text
# tool response — no regression.
COMPARE_SITES_URI = "ui://epwforge/compare-sites-v2.html"
_VIEWS_DIR = Path(__file__).parent / "views"
def _read_view(filename: str) -> str:
"""Load a bundled MCP Apps view template from the package."""
return (_VIEWS_DIR / filename).read_text(encoding="utf-8")
mcp = FastMCP("epwforge")
mcp._mcp_server.version = __version__
# ── MCP Apps UI resource: site-comparison cards ──────────────────────────────
# CSP allowlist: unpkg.com is required to load the ext-apps client library.
# No other external origins are loaded by compare-sites.html.
@mcp.resource(
COMPARE_SITES_URI,
name="Site comparison cards (interactive)",
description=(
"Interactive comparison-card view shown alongside analyze_weather "
"multi-URL results in MCP Apps-capable hosts (Claude Desktop, "
"ChatGPT, VS Code, Goose). Lets the user stress-test any compared "
"site without retyping config."
),
mime_type="text/html;profile=mcp-app",
meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}},
)
def compare_sites_view() -> str:
return _read_view("compare-sites.html")
# ── Catalog resources (mirror of hosted MCP route.ts) ───────────────────────
# These let local Python users browse the same reference catalogs as users
# who go through epwforge.com/api/mcp. The hosted MCP remains the source of
# truth for content — these are kept short and link out for full details.
@mcp.resource(
"epwforge://catalog/event-types",
name="Extreme event catalog",
description="Event types valid for the `events` config param; auto-compound pairs; intensity + duration guidance.",
mime_type="application/json",
)
def catalog_event_types() -> str:
return json.dumps({
"events": [
{"id": "heatwave", "description": "Extended heat — sustained daily high above local 95th-percentile DB."},
{"id": "coldsnap", "description": "Extended cold — sustained daily low below local 5th-percentile DB."},
{"id": "hothumid", "description": "Humidity-amplified heat. Auto-compounds with heatwave."},
{"id": "coldwindy", "description": "Wind-amplified cold. Auto-compounds with coldsnap."},
],
"compound_pairs": [["heatwave", "hothumid"], ["coldsnap", "coldwindy"]],
"intensity_scale": {
"scale": "1-7 (default), unlock 8-10 with stress_test=true",
"meanings": {
"1": "Damped — 0.5x historical extreme",
"5": "Historical baseline (default for unspecified events)",
"7": "Severe — ~50-yr return period",
"10": "Stress test — exceeds observed historical extremes",
},
},
"auto_fill": "When ssp is set and intensity is left blank, the AR6 ensemble factor for that (region, ssp, year, percentile, event) is used. Cold events stay at 5.",
"duration_guidance": {
"param": "event_duration",
"range": "3-30 days",
"default": 14,
"recommended": "14-21 for stress-test / resilience scenarios so the event spans a full work-week of operational impact",
"avoid": "≤10 days for design or building-load analysis — too short to capture sustained impact",
},
}, indent=2)
# Lazy client — only constructed when a tool actually needs it. Read tools
# work without an API key (they hit public endpoints or fetch URLs directly).
_client: EPWForgeClient | None = None
def _get_client() -> EPWForgeClient:
"""Get the (lazily-constructed) HTTP client. Does NOT require an API key."""
global _client
if _client is None:
_client = EPWForgeClient()
return _client
def _base_url() -> str:
return (os.environ.get("EPWFORGE_BASE_URL") or "https://epwforge.com").rstrip("/")
def _compact_station(s: dict[str, Any]) -> dict[str, Any]:
"""Reduce a /api/stations entry to identifiers + single best file.
Picks the newest TMYx-vintage file (highest period end-year, source
preference for TMYx) and returns only that file's epw_url + ddy_url,
dropping the rest. Typical 6-10× token reduction per station.
Used by find_station when compact=True. (QC review 2026-06-09 P2-7.)
"""
keep = {k: s[k] for k in (
"city", "state", "country", "lat", "lon", "distance_km", "wmo", "name",
) if k in s}
files = s.get("files") or []
if files:
def _file_score(f: dict[str, Any]) -> tuple[int, int]:
source = (f.get("source") or "").upper()
period = f.get("period") or ""
# Prefer TMYx over TMY3 / CWEC / IWEC. Then newest end-year.
tmyx_pref = 1 if "TMYX" in source else 0
end_year = 0
if "-" in period:
try:
end_year = int(period.split("-")[-1])
except ValueError:
pass
return (tmyx_pref, end_year)
best = max(files, key=_file_score)
if best.get("epw_url"): keep["epw_url"] = best["epw_url"]
if best.get("ddy_url"): keep["ddy_url"] = best["ddy_url"]
if best.get("source"): keep["best_file_source"] = best["source"]
if best.get("period"): keep["best_file_period"] = best["period"]
keep["files_omitted"] = len(files) - 1
return keep
# Default to the latest TMYx EPW from the nearest real OneBuilding station
# when the caller passes a `config` (lat/lon). Synthesized custom-location
# TMYx is reserved as a fallback for genuinely remote sites; the caller must
# explicitly opt into it with allow_custom_location=true.
STATION_DISTANCE_THRESHOLD_KM = 50.0
# (Neutered 2026-06-18) This previously hard-rejected ssp585. SSP5-8.5 is now an
# accepted opt-in EXTREME STRESS-TEST scenario, so there is nothing to reject. Kept
# as a no-op so the existing call sites (analyze_weather / chart_weather /
# generate_weather_file / batch) don't need touching; the Literal enums below now
# include ssp585, and the hosted backend accepts it like any other SSP.
def _assert_ssp_allowed(*args: Any) -> None:
"""No-op. ssp585 is a valid stress-test scenario; retained for call-site compat."""
return None
async def _resolve_base_url_for_config(
cfg: dict[str, Any] | None,
*,
allow_custom_location: bool,
) -> dict[str, Any] | None:
"""Look up the nearest OneBuilding station and return its EPW URL + label
for use as the morph base. Returns None when caller has opted into custom
synthesis (allow_custom_location=true) and no station is within threshold.
Raises EPWForgeError when no station is nearby and the caller hasn't opted
in (forcing the agent to confirm with the user)."""
if not isinstance(cfg, dict):
return None
lat, lon = cfg.get("lat"), cfg.get("lon")
if lat is None or lon is None:
return None
# AMY basis is necessarily synthesized (per-year hourly from ERA5).
if cfg.get("basis") == "amy":
return None
try:
resp = await _call_hosted_mcp("find_station", {"lat": lat, "lon": lon, "max_results": 1})
except Exception:
resp = {}
stations = resp.get("stations", []) if isinstance(resp, dict) else []
nearest = stations[0] if stations else None
dist_km = nearest.get("distance_km") if isinstance(nearest, dict) else None
files = nearest.get("files") if isinstance(nearest, dict) else None
target = None
if isinstance(files, list):
target = next((f for f in files if f.get("source") == "TMYx" and f.get("period") == "2011-2025"), None)
if not target:
target = next((f for f in files if f.get("source") == "TMYx"), None) or (files[0] if files else None)
if not target or not target.get("epw_url"):
if allow_custom_location:
return None
raise EPWForgeError(
404,
f"No OneBuilding TMYx EPW found near ({lat:.3f}, {lon:.3f}). "
"Confirm with the user that a synthesized custom-location TMYx (ERA5 + "
"Finkelstein-Schafer) is acceptable, then retry with allow_custom_location=true."
)
if isinstance(dist_km, (int, float)) and dist_km > STATION_DISTANCE_THRESHOLD_KM:
if not allow_custom_location:
raise EPWForgeError(
404,
f"Nearest OneBuilding station ({nearest.get('city', '?')}, {nearest.get('country', '?')}) "
f"is {dist_km:.0f} km from ({lat:.3f}, {lon:.3f}) — exceeds the {STATION_DISTANCE_THRESHOLD_KM:.0f} km "
"threshold for a real-station default. Ask the user if a synthesized "
"custom-location TMYx (ERA5 + Finkelstein-Schafer) is acceptable for this work, "
"then retry with allow_custom_location=true."
)
return None
return {
"base_url": target["epw_url"],
"base_url_label": (
f"Real {target.get('source','TMYx')} {target.get('period','')}".strip()
+ f" from {nearest.get('city','nearest station')}"
+ (f" ({dist_km:.0f} km)" if isinstance(dist_km, (int, float)) else "")
),
"station": nearest,
"file": target,
}
# ============================================================================
# Tool 1: find_station — no auth needed
# ============================================================================
@mcp.tool()
async def find_station(
query: Annotated[
str | None,
Field(description="Case-insensitive partial match on city / state. e.g. 'Boston', 'Manhattan'."),
] = None,
lat: Annotated[
float | None,
Field(ge=-90, le=90, description="Latitude — when set with lon, results sort by proximity."),
] = None,
lon: Annotated[
float | None,
Field(ge=-180, le=180, description="Longitude. Pair with lat."),
] = None,
country: Annotated[
str | None,
Field(description="ISO 3-letter country code filter, e.g. 'USA', 'GBR', 'JPN'."),
] = None,
max_results: Annotated[
int,
Field(ge=1, le=50, description="Max stations to return (default 10)."),
] = 10,
include_amy_extremes: Annotated[
bool,
Field(description="When True with lat+lon, also returns the hottest/coldest/most-humid years on record (per ERA5). Routes through hosted MCP."),
] = False,
include_climate_deltas: Annotated[
bool,
Field(description="When True with lat+lon+ssp+year, also returns the monthly CMIP6 delta-T. Routes through hosted MCP."),
] = False,
ssp: Annotated[
Literal["ssp126", "ssp245", "ssp370", "ssp585"] | None,
Field(description="SSP scenario (only used with include_climate_deltas). ssp370 is the recommended high-end for design; ssp585 (SSP5-8.5) is an opt-in extreme stress-test pathway for worst-case analysis."),
] = None,
year: Annotated[
Literal[2030, 2035, 2040, 2045, 2050, 2060, 2070, 2080, 2090, 2100] | None,
Field(description="Future horizon (only used with include_climate_deltas)"),
] = None,
percentile: Annotated[
Literal[5, 10, 25, 50, 75, 90, 95],
Field(description="Warming percentile (only used with include_climate_deltas, default 50)"),
] = 50,
compact: Annotated[
bool,
Field(description=(
"When True, return only the single newest TMYx file per station "
"(epw_url + ddy_url + source/period) plus identifiers. "
"Default False returns every vintage's full URL set. "
"Use compact=True in chained agent workflows — typical agents "
"only pick one file, and the full form can be 6-10× larger in "
"context. (Added 2026-06-09 per QC review P2-7.)"
)),
] = False,
) -> dict[str, Any]:
"""Search the GuzzStations catalog (17,000+ weather stations worldwide).
Optional enrichments (route through hosted MCP for the extra queries):
- include_amy_extremes: hottest/coldest/most-humid years on record
- include_climate_deltas: monthly CMIP6 delta-T for the picked scenario
No authentication required for any mode.
Examples:
find_station(query="Denver")
find_station(lat=40.7, lon=-74.0, max_results=5)
find_station(country="JPN", query="Tokyo")
find_station(lat=40.7, lon=-74.0, include_amy_extremes=True)
find_station(lat=40.7, lon=-74.0, include_climate_deltas=True, ssp="ssp245", year=2050)
"""
# If any enrichment is requested, the hosted MCP handles the fan-out to
# /api/amy-extremes and /api/climate-deltas (single round-trip vs 3).
if include_amy_extremes or include_climate_deltas:
return await _call_hosted_mcp("find_station", {
"query": query, "lat": lat, "lon": lon, "country": country, "max_results": max_results,
"include_amy_extremes": include_amy_extremes,
"include_climate_deltas": include_climate_deltas,
"ssp": ssp, "year": year, "percentile": percentile,
})
params: dict[str, Any] = {"limit": max_results}
if query: params["q"] = query
if lat is not None: params["lat"] = lat
if lon is not None: params["lon"] = lon
if country: params["country"] = country
async with httpx.AsyncClient(
timeout=15.0,
headers={"User-Agent": "epwforge-mcp"},
follow_redirects=True,
) as c:
resp = await c.get(f"{_base_url()}/api/stations", params=params)
if resp.status_code >= 400:
raise EPWForgeError(resp.status_code, f"find_station failed (HTTP {resp.status_code}): {resp.text[:200]}")
data = resp.json()
stations = data.get("stations", [])
# P2-7: compact each station to (identifiers + single best file) when
# the caller opted in. Picks the newest TMYx vintage and drops the rest.
if compact:
stations = [_compact_station(s) for s in stations]
nearest_km = None
if stations:
try:
nearest_km = min(s["distance_km"] for s in stations if s.get("distance_km") is not None)
except (ValueError, KeyError):
nearest_km = None
if nearest_km is None:
nudge = "No matches. Try a broader query (city only) or pass lat/lon for proximity sort."
elif nearest_km <= 25:
nudge = (
f"Nearest station is {nearest_km:.0f} km away — almost certainly representative. "
"Use any station's epw_url with analyze_weather or chart_weather."
)
elif nearest_km <= 100:
nudge = (
f"Nearest station is {nearest_km:.0f} km — may differ for microclimates "
"(urban core, mountain, coastal). For exact-coordinate weather, use "
"generate_weather_file (requires API key + credits) or analyze_weather "
"with a config (no auth needed, returns stats only)."
)
else:
nudge = (
f"Nearest station is {nearest_km:.0f} km — likely a different climate. "
"Consider analyze_weather with a config for a synthesized TMYx at the exact lat/lon."
)
return {
"count": len(stations),
"stations": stations,
"agent_guidance": nudge,
"nearest_km": nearest_km,
"meta": _meta("find_station"),
}
# ============================================================================
# Tool 2: analyze_weather — no auth needed (URL, urls, or config)
# ============================================================================
@mcp.tool(meta={
# MCP Apps (SEP-1865): when multi-URL results are returned to an Apps-
# capable host, render the compare-sites card view instead of raw JSON.
# The view itself decides whether to render (presence of `summaries[]`)
# — single-URL and config-mode calls still show as text.
"ui": {"resourceUri": COMPARE_SITES_URI},
"ui/resourceUri": COMPARE_SITES_URI, # legacy spec key
})
async def analyze_weather(
url: Annotated[
str | None,
Field(description="EPW URL to analyze (single file). Pass this for a single-file stats summary."),
] = None,
urls: Annotated[
list[str] | None,
Field(
min_length=2,
max_length=10,
description=(
"Multiple EPW URLs to compare (2-10) in ONE call. First is the baseline; "
"others are reported as deltas from it. **Use this for ANY multi-site "
"comparison** (data center siting, climate-zone spread, portfolio "
"resilience). The card UI renders all sites side-by-side with future "
"deltas inline. Do NOT loop analyze_weather with single configs to fake "
"a comparison — pass all URLs here."
),
),
] = None,
config: Annotated[
dict[str, Any] | None,
Field(
description=(
"Synthesize a SINGLE morphed EPW server-side and analyze it. Required: "
"`lat` (-90..90), `lon` (-180..180). Common params:\n"
" • ssp: 'ssp126'|'ssp245'|'ssp370' — emission scenario. "
"ssp370 is the recommended high-end for design; 'ssp585' (SSP5-8.5) "
"is an opt-in extreme stress test (~4.4°C, low likelihood). Default "
"no SSP = present-day TMY.\n"
" • year: 2030|2035|2040|2045|2050|2060|2070|2080|2090|2100 — future horizon (5-yr through 2050, 10-yr after). Pair with ssp.\n"
" • percentile: 5|10|25|50|75|90|95 — warming percentile across CMIP6 "
"models. **Use 75 for design-realistic warming; 50 is the median and "
"underestimates the tail for siting/sizing work.** Default 50.\n"
" • uhi: 'none'|'suburban'|'urban'|'dense_urban' — UHI preset.\n"
" • events: comma-separated string of 'heatwave','coldsnap','hothumid',"
"'coldwindy'. Auto-compounds heat+humid and cold+wind pairs.\n"
" • intensity: per-event string like 'heatwave:8,coldsnap:7'. 5 = "
"typical extreme, 7 = severe ~50-yr return, 8-10 = stress-test (requires "
"stress_test=true). Leave blank with ssp set to auto-fill from AR6.\n"
" • event_duration: integer 3-30, **default 14**. For stress-test or "
"resilience scenarios use 14-21 days — shorter durations don't capture "
"sustained operational impact. 7 is too short for any design work.\n"
" • smoke: bool. smoke_intensity: 1-10 (peak AOD 0.1-6.0). "
"smoke_duration: 3-30 days, default 14 (NOT 7).\n"
" • stress_test: bool — unlocks intensity 8-10.\n"
"Use for: (a) drilling into ONE site at a specific future scenario, or "
"(b) stress-testing event compounds. **Never loop for multi-site work** "
"— use `urls=[...]` instead. Routes through the hosted MCP — runs the "
"full morph/UHI/event pipeline and returns ONLY stats. Anon-safe."
)
),
] = None,
compact: Annotated[
bool,
Field(description="Token-saver. Returns a ~10-field headline-only response (~100 tokens) instead of the full ~800-token payload — drops monthly arrays, peak days, n_hours, weather_basis. Good for sanity checks, dashboards, batched chained calls. Set false (default) when you need the full payload. Routes through hosted MCP."),
] = False,
include_full_ashrae: Annotated[
bool,
Field(description="Adds ASHRAE 0.4%/1%/2% cooling DB + 99.6%/99% heating DB design conditions. Ignored when compact=true. Routes through hosted MCP."),
] = False,
include_improbability: Annotated[
bool,
Field(description="Adds EPWForge's stress-test improbability score (config mode only). Routes through hosted MCP."),
] = False,
include_idf: Annotated[
bool,
Field(description="Adds ready-to-paste EnergyPlus SizingPeriod:DesignDay IDF objects to the response. Routes through hosted MCP."),
] = False,
units: Annotated[
Literal["imperial", "metric"],
Field(description="Output units (default imperial). When 'metric', temperatures are °C, HDD/CDD base 18 °C, elevation in m. Routes through hosted MCP."),
] = "imperial",
include_future_projection: Annotated[
bool,
Field(
description=(
"When true (default for multi-URL comparisons) runs the SSP 3-7.0 P75 2050 "
"morph pipeline per site (via hosted MCP) and embeds future-projected "
"design conditions and CDD-65 deltas under each summary's `future_projection`. "
"Lets a UI show '92.8 → 97.7 °F' baseline→future on each card. Per CMIP7 "
"guidance, SSP 3-7.0 is the credible upper bound (SSP 5-8.5 was deemed "
"implausible). P75 is the design-realistic warming percentile vs P50 median. "
"Adds N hosted MCP calls; parallelized via asyncio.gather. Free. Set to false "
"to skip for a faster baseline-only response."
)
),
] = True,
allow_custom_location: Annotated[
bool,
Field(
description=(
"Required to fall back to synthesized TMYx when no real OneBuilding "
"station is within 50 km of the requested lat/lon. By default, config-mode "
"uses the nearest real station's TMYx EPW as the morph base — synthesizing "
"from ERA5 is reserved for genuinely remote sites. If the nearest station "
"is >50 km away and this flag is false, the call returns an error asking "
"you to confirm a custom synthesized location is acceptable, then retry "
"with allow_custom_location=true."
)
),
] = False,
) -> dict[str, Any]:
"""Compute design conditions, HDD/CDD, monthly stats, and peak days for one
or more EPW files. No EPW content returned — stats only.
Three modes:
1. Single URL: analyze_weather(url="https://...")
2. Multi-URL comparison: analyze_weather(urls=["...", "...", "..."])
3. Synthesized config: analyze_weather(config={"lat": 40.7, "lon": -74,
"ssp": "ssp370", "year": 2050, "uhi": "urban"})
⚠ CRITICAL ROUTING RULE — read before calling:
If the user is comparing N sites (data-center siting, climate-zone
spread, portfolio resilience, etc.) you MUST:
1. Call `find_station` once per city to get its EPW URL
2. Call `analyze_weather` EXACTLY ONCE with `urls=[all N urls]`
Do NOT call analyze_weather N times in a loop with single configs.
That breaks the comparison card UI (each call renders a separate
blank widget), produces no future-projection deltas, and is slow.
`include_future_projection=true` (the default for url-mode) embeds
SSP 3-7.0 P75 2050 design conditions per site in one shot, which is
what the inline card UI needs.
Use `config` mode ONLY for: (a) a single site morphed to a specific
future scenario, or (b) stress-testing event compounds for one site.
Never use config mode in a loop to fake a comparison.
Modes 1 + 2 download the URLs and parse locally (purely client-side).
Mode 3 routes through the hosted EPWForge MCP so the morph/UHI/event/smoke
pipeline runs on EPWForge infrastructure — the synthesized EPW never
leaves the server. Use mode 3 to preview a future-climate scenario or
a UHI / extreme-event sensitivity without spending credits.
No authentication required for any mode.
"""
inputs_set = [x for x in (url, urls, config) if x is not None]
if len(inputs_set) != 1:
raise ValueError("analyze_weather requires exactly one of: url, urls, config")
_assert_ssp_allowed(config)
# Post-processing hook: when the caller wants future-projected design
# conditions, apply CMIP6 monthly delta-T values **to the real baseline**
# (no synthesis). Belcher-style mean shift only at the design-condition
# level. For each site, fetch climate_deltas via find_station and apply
# to that site's real baseline. Total cost: N parallel hosted calls,
# ~3-5s end-to-end.
async def _attach_future_projection(summaries_in: list[dict[str, Any]]) -> None:
if not include_future_projection or not summaries_in:
return
FUTURE_SSP = "ssp370"
FUTURE_YEAR = 2050
FUTURE_PCT = 75
async def _future_for(s: dict[str, Any]) -> dict[str, Any]:
loc = s.get("location") or {}
lat, lon = loc.get("lat"), loc.get("lon")
if lat is None or lon is None:
return {"error": "missing lat/lon on baseline EPW"}
try:
# Fetch monthly CMIP6 delta-T (°C) for this site/scenario.
# find_station with include_climate_deltas returns deltas at
# top level; no EPW synthesis happens.
resp = await _call_hosted_mcp("find_station", {
"lat": lat, "lon": lon,
"include_climate_deltas": True,
"ssp": FUTURE_SSP, "year": FUTURE_YEAR, "percentile": FUTURE_PCT,
"max_results": 1,
})
cd = resp.get("climate_deltas") if isinstance(resp, dict) else None
if not cd or not cd.get("delta_temp") or len(cd["delta_temp"]) != 12:
return {"error": "climate deltas unavailable", "ssp": FUTURE_SSP, "year": FUTURE_YEAR, "percentile": FUTURE_PCT}
# 12 monthly delta-T (°C → °F)
d_f = [v * 9.0 / 5.0 for v in cd["delta_temp"]]
base_cool = s.get("cooling_design_db_F")
base_heat = s.get("heating_design_db_F")
base_cdd = s.get("cdd_65_annual")
base_hdd = s.get("hdd_65_annual")
base_monthly = s.get("monthly_mean_temp_F") or []
base_annual_mean = s.get("annual_mean_temp_F")
# Cooling design (99% annual DB) peaks in summer: apply max of Jun/Jul/Aug delta.
summer_delta = max(d_f[5], d_f[6], d_f[7]) if len(d_f) == 12 else max(d_f)
# Heating design (1% annual DB) hits in winter: apply min of Dec/Jan/Feb delta.
winter_delta = min(d_f[11], d_f[0], d_f[1]) if len(d_f) == 12 else min(d_f)
# Recompute CDD/HDD from monthly means + month-wise deltas (approx, monthly mean basis).
future_cdd = future_hdd = None
if len(base_monthly) == 12:
DAYS = [31, 28.25, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
future_monthly = [m + d for m, d in zip(base_monthly, d_f)]
future_cdd = round(sum(max(0.0, m - 65) * d for m, d in zip(future_monthly, DAYS)))
future_hdd = round(sum(max(0.0, 65 - m) * d for m, d in zip(future_monthly, DAYS)))
cdd_pct = round((future_cdd - base_cdd) / base_cdd * 100) if (base_cdd and future_cdd and base_cdd > 0) else None
annual_delta_F = round(sum(d_f) / 12, 1) if len(d_f) == 12 else None
future_annual_mean = round(base_annual_mean + annual_delta_F, 1) if (base_annual_mean is not None and annual_delta_F is not None) else None
return {
"ssp": FUTURE_SSP,
"year": FUTURE_YEAR,
"percentile": FUTURE_PCT,
"method": "CMIP6 monthly delta-T applied to real baseline (no EPW synthesis)",
"cooling_design_db_F": round(base_cool + summer_delta, 1) if base_cool is not None else None,
"heating_design_db_F": round(base_heat + winter_delta, 1) if base_heat is not None else None,
"annual_mean_temp_F": future_annual_mean,
"cdd_65_annual": future_cdd,
"hdd_65_annual": future_hdd,
"cdd_pct_delta": cdd_pct,
}
except Exception as e:
return {"error": str(e), "ssp": FUTURE_SSP, "year": FUTURE_YEAR, "percentile": FUTURE_PCT}
futures = await asyncio.gather(*(_future_for(s) for s in summaries_in))
for s, fp in zip(summaries_in, futures):
s["future_projection"] = fp
# Helper: reverse-geocode lat/lon to a nearby GuzzStation name. Used for
# config-mode responses that come back with location="Custom, Unknown".
async def _nearest_station(lat: float | None, lon: float | None) -> dict[str, Any] | None:
if lat is None or lon is None:
return None
try:
async with httpx.AsyncClient(timeout=8.0, headers={"User-Agent": "epwforge-mcp"}, follow_redirects=True) as c:
r = await c.get(f"{_base_url()}/api/stations", params={"lat": lat, "lon": lon, "limit": 1})
if r.status_code == 200:
stations = r.json().get("stations", [])
if stations:
return stations[0]
except Exception:
pass
return None
def _apply_station_to_location(result_obj: dict[str, Any], nearest: dict[str, Any]) -> None:
loc = result_obj.get("location") or {}
is_generic = (
not loc.get("city")
or loc.get("city") in ("Custom", "Unknown", "")
or not loc.get("state")
or loc.get("country") in ("Unknown", "", None)
)
# Elevation backfill is INDEPENDENT of the city/state generic check.
# The hosted morph pipeline can return a named station (e.g.
# "Beverly Rgnl AP") with elevation_ft=0, in which case is_generic
# is False and the pre-2026-06-09 `if not is_generic: return` skipped
# this fill entirely. Result: synthesized / morphed EPWs from elevated
# sites got sea-level air density and sea-level barometric pressure
# in their DesignDay objects. Always backfill elevation when missing
# or zero, regardless of city state. (QC review 2026-06-09 P0-1.)
elevation_changed = False
if (not loc.get("elevation_ft") or loc.get("elevation_ft") == 0) and nearest.get("elevation_m") is not None:
loc["elevation_ft"] = round(nearest["elevation_m"] * 3.28084)
elevation_changed = True
# Name backfill remains gated on is_generic.
name_changed = False
if is_generic:
loc["city"] = nearest.get("city") or nearest.get("name") or loc.get("city") or "Unknown"
loc["state"] = nearest.get("state") or loc.get("state", "")
loc["country"] = nearest.get("country") or loc.get("country") or "Unknown"
name_changed = True
result_obj["location"] = loc
# Annotate provenance only when we actually changed something.
if elevation_changed or name_changed:
dist = nearest.get("distance_km")
tags = []
if name_changed: tags.append("city")
if elevation_changed: tags.append("elevation")
result_obj.setdefault("location_meta", {})["enriched_from"] = (
f"nearest station ({dist:.0f} km, fields: {'+'.join(tags)})"
if isinstance(dist, (int, float))
else f"nearest station (fields: {'+'.join(tags)})"
)
async def _enrich_config_location(result_obj: Any, cfg: dict[str, Any] | None) -> None:
"""If a config-mode result has a generic Custom/Unknown location, fill it via nearest station."""
if not isinstance(result_obj, dict) or not isinstance(cfg, dict):
return
nearest = await _nearest_station(cfg.get("lat"), cfg.get("lon"))
if nearest:
_apply_station_to_location(result_obj, nearest)
# _resolve_base_url_for_config is module-level so chart_weather can reuse it.
# See module-level definition near top of file.
# Anything in the config that triggers the morph/event/smoke/UHI pipeline.
# When set, the result is a *modified* scenario — useless without baseline
# for comparison.
def _config_is_morphed(cfg: dict[str, Any] | None) -> bool:
if not isinstance(cfg, dict):
return False
if cfg.get("ssp") or cfg.get("year"):
return True
if cfg.get("uhi") and cfg.get("uhi") != "none":
return True
if cfg.get("events") or cfg.get("intensity"):
return True
if cfg.get("smoke"):
return True
if cfg.get("stress_test"):
return True
return False
async def _attach_baseline_reference(result_obj: Any, cfg: dict[str, Any] | None) -> None:
"""For config-mode stress/morph results, fetch the **real OneBuilding
TMYx EPW** at the nearest station and use it as the baseline reference
so the UI can render '88 → 101 °F' deltas. Skipped when the config is
already baseline (no morphing params). Never synthesizes — uses the
actual file a user would download via find_station."""
if not isinstance(result_obj, dict) or not isinstance(cfg, dict):
return
if not _config_is_morphed(cfg):
return
lat, lon = cfg.get("lat"), cfg.get("lon")
if lat is None or lon is None:
return
try:
# Step 1: nearest OneBuilding station + its TMYx EPW URL
resp = await _call_hosted_mcp("find_station", {
"lat": lat, "lon": lon, "max_results": 1,
})
stations = resp.get("stations", []) if isinstance(resp, dict) else []
if not stations:
return
files = stations[0].get("files") or []
# Prefer 2011-2025 TMYx; fall back to first available.
preferred = next((f for f in files if f.get("source") == "TMYx" and f.get("period") == "2011-2025"), None)
target = preferred or (files[0] if files else None)
if not target or not target.get("epw_url"):
return
# Step 2: fetch and parse the real EPW (no synthesis)
text = await download_text(target["epw_url"])
baseline_epw = parse_epw(text)
baseline = _summarize_epw(baseline_epw, source_url=target["epw_url"])
dist_km = stations[0].get("distance_km")
result_obj["baseline_reference"] = {
"cooling_design_db_F": baseline.get("cooling_design_db_F"),
"heating_design_db_F": baseline.get("heating_design_db_F"),
"annual_mean_temp_F": baseline.get("annual_mean_temp_F"),
"cdd_65_annual": baseline.get("cdd_65_annual"),
"hdd_65_annual": baseline.get("hdd_65_annual"),
"source_url": target["epw_url"],
"source_label": f"Real TMYx from {stations[0].get('city', 'nearest station')}" + (f" ({dist_km:.0f} km away)" if isinstance(dist_km, (int, float)) else ""),
"vintage": f"{target.get('source', 'TMYx')} {target.get('period', '')}".strip(),
"method": "Real OneBuilding TMYx EPW (no synthesis)",
}
except Exception:
# Silent fallback: don't break the morphed response if baseline lookup fails.
pass
# If any enrichment (or metric units, or compact mode) is requested,
# route through hosted MCP — it has the IDF emitter, full-ASHRAE
# computation, improbability scorer, compact projection, and the
# unit-converted summarizer all in lib.
if include_full_ashrae or include_improbability or include_idf or units == "metric" or compact:
payload: dict[str, Any] = {
"include_full_ashrae": include_full_ashrae,
"include_improbability": include_improbability,
"include_idf": include_idf,
"units": units,
"compact": compact,
}
if url: payload["url"] = url
if urls: payload["urls"] = urls
if config:
# Inject real-station base_url when one is within threshold; raises
# EPWForgeError telling the agent to confirm custom synth if not.
base = await _resolve_base_url_for_config(config, allow_custom_location=allow_custom_location)
cfg_with_base = {**config}
if base:
cfg_with_base["base_url"] = base["base_url"]
cfg_with_base["base_url_label"] = base["base_url_label"]
payload["config"] = cfg_with_base
result = await _call_hosted_mcp("analyze_weather", payload)
# Hosted MCP doesn't yet know about include_future_projection — we
# post-process its summaries locally to attach it. Works for either
# multi-URL (summaries[] array) or single-URL (result IS the summary).
if include_future_projection and isinstance(result, dict):
if urls:
inner_summaries = result.get("summaries")
if isinstance(inner_summaries, list):
await _attach_future_projection(inner_summaries)
elif url:
await _attach_future_projection([result])
# Config mode + any enrichment came back with "Custom/Unknown" location
# because hosted MCP doesn't reverse-geocode lat/lon. Fix it. Also attach
# a baseline reference so the UI can show before→after deltas for any
# morphed/stressed scenario.
if config:
await asyncio.gather(
_enrich_config_location(result, config),
_attach_baseline_reference(result, config),
)
return result
# Single URL — local fetch + parse, with optional future projection.
if url:
text = await download_text(url)
epw = parse_epw(text)
summary = _summarize_epw(epw, source_url=url)
if include_future_projection:
await _attach_future_projection([summary])
return summary
# Multi-URL comparison — parallel fetch + parse, deltas vs first.
if urls:
async def _one(u: str) -> dict[str, Any]:
text = await download_text(u)
return _summarize_epw(parse_epw(text), source_url=u)
summaries = list(await asyncio.gather(*(_one(u) for u in urls)))
await _attach_future_projection(summaries)
baseline = summaries[0]
comparisons = [
{
"source_url": s["source_url"],
"cooling_db_delta_F": round(s["cooling_design_db_F"] - baseline["cooling_design_db_F"], 1),
"heating_db_delta_F": round(s["heating_design_db_F"] - baseline["heating_design_db_F"], 1),
"annual_mean_temp_delta_F": round(s["annual_mean_temp_F"] - baseline["annual_mean_temp_F"], 1),
}
for s in summaries[1:]
]
return {
"baseline_url": baseline["source_url"],
"count": len(summaries),
"summaries": summaries,
"comparisons": comparisons,
"meta": _meta("analyze_weather", mode="compare", n_urls=len(urls), future_projection=include_future_projection),
}
# config mode — route through hosted MCP for the pipeline run, then
# enrich the location and attach baseline reference if morphed.
# First inject real-station base_url so morph operates on the actual
# OneBuilding TMYx (or raise asking the agent to confirm custom synth).
base = await _resolve_base_url_for_config(config, allow_custom_location=allow_custom_location)
cfg_with_base = {**config}
if base:
cfg_with_base["base_url"] = base["base_url"]
cfg_with_base["base_url_label"] = base["base_url_label"]
morph = await _call_hosted_mcp("analyze_weather", {"config": cfg_with_base})
await asyncio.gather(
_enrich_config_location(morph, config),
_attach_baseline_reference(morph, config),
)
return morph
# ============================================================================
# Tool 3: chart_weather — no auth needed (URL, urls, or config)
# ============================================================================
@mcp.tool()
async def chart_weather(
url: Annotated[
str | None,
Field(description="EPW URL (for chart_type='diurnal')."),
] = None,
urls: Annotated[
list[str] | None,
Field(
min_length=2,
max_length=10,
description="EPW URLs for chart_type='comparison' (first = baseline).",
),
] = None,
config: Annotated[
dict[str, Any] | None,
Field(
description=(
"Synthesize an EPW server-side and chart it. Same params as "
"generate_weather_file. Routes through hosted MCP — pipeline "
"runs on EPWForge infra, only SVG returned. Anon-safe."
)
),
] = None,
chart_type: Annotated[
Literal[
"diurnal", "temp_carpet", "wind_rose", "monthly_boxplot",
"utci_carpet", "economizer_carpet", "pv_tilt_azimuth", "solar_under_events",
"comparison",
],
Field(description=(
"Chart type. diurnal = monthly Max/Avg/Min hourly profile. "
"temp_carpet = heatmap of hour x day-of-year. "
"wind_rose = polar bars of direction x speed. "
"monthly_boxplot = Q1/median/Q3 + whiskers per month. "
"utci_carpet = outdoor heat-stress hour×day, UTCI categories (Bröde 2012, shaded Tmrt). "
"economizer_carpet = air-side economizer free/integrated/locked-out hour×day under ASHRAE 90.1 high-limit. "
"pv_tilt_azimuth = annual PV generation across full tilt×azimuth grid, optimum marked. "
"solar_under_events = weekly GHI of modified scenario vs no-overlay reference, event-affected weeks banded. Requires config. "
"comparison = design-condition delta bars (needs urls)."
)),
] = "diurnal",
resolution: Annotated[
Literal["preview", "full"],
Field(description=(
"temp_carpet only. 'preview' (default) ~150 KB with 32-color quantization. "
"'full' ~600 KB with per-cell rgb() — exact fidelity. Either way, "
"outputs over 50 KB auto-upload to Blob (hosted MCP) and return svg_url "
"instead of inline svg — keeps your context lean."
)),
] = "preview",
econ_mode: Annotated[
Literal["drybulb", "enthalpy"],
Field(description=(
"economizer_carpet only. 'drybulb' (default) limits OA by Tdb; "
"'enthalpy' limits by moist-air enthalpy — more honest in humid climates."
)),
] = "drybulb",
econ_high_limit_f: Annotated[
float | None,
Field(description="economizer_carpet only. ASHRAE 90.1 high-limit shutoff. Defaults: 75°F (drybulb) or 28 BTU/lb (enthalpy)."),
] = None,
econ_supply_air_f: Annotated[
float | None,
Field(description="economizer_carpet only. Supply-air temperature setpoint (°F). Default 55."),
] = None,
pv_tilt: Annotated[
float | None,
Field(description="pv_tilt_azimuth only. Optional — marks user's planned tilt (deg, 0=horizontal) alongside the optimum."),
] = None,
pv_azimuth: Annotated[
float | None,
Field(description="pv_tilt_azimuth only. Optional — marks user's planned compass azimuth (deg, 0=N, 180=S) alongside the optimum."),
] = None,
save_to: Annotated[
str | None,
Field(description="When set, writes SVG to this path and returns the path (saves agent context)."),
] = None,
allow_custom_location: Annotated[
bool,
Field(description=(
"Required to fall back to synthesized TMYx when no real OneBuilding station "
"is within 50 km of the requested lat/lon (config mode only). Default false: "
"config-mode chart uses the nearest real station's TMYx EPW as the morph base. "
"If no station nearby and this flag is false, the call returns an error asking "
"you to confirm a custom synthesized location is acceptable."
)),
] = False,
) -> dict[str, Any]:
"""Render an SVG chart from EPW data.
chart_type='diurnal' — monthly Max / Avg / Min hourly temperature profile
in °F (January and July highlighted, annual mean overlaid). Pass `url`
or `config`.
chart_type='comparison' — horizontal-bar chart of cooling/heating
deltas across multiple EPWs. Pass `urls` (first = baseline).
No authentication required for any mode.
"""
inputs_set = [x for x in (url, urls, config) if x is not None]
if len(inputs_set) != 1:
raise ValueError("chart_weather requires exactly one of: url, urls, config")
_assert_ssp_allowed(config)
# Charts that live only on the hosted MCP: routed there as-is. The
# 0.8.0 additions (utci_carpet, economizer_carpet, pv_tilt_azimuth,
# solar_under_events) all run on EPWForge infra — no client-side
# implementation. solar_under_events specifically requires config
# (server needs to run the pipeline twice — with and without overlays).
HOSTED_ONLY = ("temp_carpet", "wind_rose", "monthly_boxplot",
"utci_carpet", "economizer_carpet", "pv_tilt_azimuth",
"solar_under_events")
if chart_type in HOSTED_ONLY:
if chart_type == "solar_under_events" and not config:
raise ValueError(
"solar_under_events requires `config` — the chart needs both the "
"modified scenario and a no-overlay reference, which the server "
"synthesizes from the same config (overlays stripped for the reference)."
)
payload: dict[str, Any] = {"chart_type": chart_type, "resolution": resolution}
if url: payload["url"] = url
if urls: payload["urls"] = urls
if config:
# Real-station default (50 km threshold) — see analyze_weather for rationale.
base = await _resolve_base_url_for_config(config, allow_custom_location=allow_custom_location)
cfg_with_base = {**config}