greenpick 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {greenpick-0.1.2/src/greenpick.egg-info → greenpick-0.1.4}/PKG-INFO +4 -3
  2. {greenpick-0.1.2 → greenpick-0.1.4}/README.md +3 -2
  3. {greenpick-0.1.2 → greenpick-0.1.4}/pyproject.toml +1 -1
  4. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/regions.json +1 -2
  6. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/models.py +1 -0
  7. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/server.py +3 -1
  8. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/locality_service.py +4 -0
  9. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/rightsizing_service.py +35 -11
  10. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/serving_service.py +6 -1
  11. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/training_service.py +3 -0
  12. {greenpick-0.1.2 → greenpick-0.1.4/src/greenpick.egg-info}/PKG-INFO +4 -3
  13. {greenpick-0.1.2 → greenpick-0.1.4}/tests/test_services.py +77 -10
  14. {greenpick-0.1.2 → greenpick-0.1.4}/LICENSE +0 -0
  15. {greenpick-0.1.2 → greenpick-0.1.4}/setup.cfg +0 -0
  16. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/__main__.py +0 -0
  17. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/config.py +0 -0
  18. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/__init__.py +0 -0
  19. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/auto_repository.py +0 -0
  20. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/eia_repository.py +0 -0
  21. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/electricitymaps_repository.py +0 -0
  22. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/emission_factors.json +0 -0
  23. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/entsoe_repository.py +0 -0
  24. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/gpu_power.json +0 -0
  25. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/inference_energy.json +0 -0
  26. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/instance_families.json +0 -0
  27. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/mock_repository.py +0 -0
  28. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/network.json +0 -0
  29. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/power.json +0 -0
  30. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/pricing.json +0 -0
  31. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/repository.py +0 -0
  32. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/data/static_data.py +0 -0
  33. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/__init__.py +0 -0
  34. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/carbon_service.py +0 -0
  35. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/cost_service.py +0 -0
  36. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/forecast_service.py +0 -0
  37. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/inference_service.py +0 -0
  38. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/instance_service.py +0 -0
  39. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/latency_service.py +0 -0
  40. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/metric_scaffold_service.py +0 -0
  41. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/recommender.py +0 -0
  42. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/residency_service.py +0 -0
  43. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick/services/scheduling_service.py +0 -0
  44. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick.egg-info/SOURCES.txt +0 -0
  45. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick.egg-info/dependency_links.txt +0 -0
  46. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick.egg-info/entry_points.txt +0 -0
  47. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick.egg-info/requires.txt +0 -0
  48. {greenpick-0.1.2 → greenpick-0.1.4}/src/greenpick.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
5
5
  Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
6
6
  License-Expression: MIT
@@ -193,8 +193,9 @@ See `docs/user-questions-backlog.md`.
193
193
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
194
194
  - Latency: great-circle distance from region coordinates to the user's location.
195
195
 
196
- Set `EIA_API_KEY` and `ENTSOE_TOKEN` in `.env` (both free) for live carbon; `GREENPICK_CARBON_SOURCE`
197
- selects the source (`auto` routes US→EIA, EU→ENTSO-E, else mock).
196
+ Set `EIA_API_KEY` and `ENTSOE_TOKEN` (both free) for live carbon — see
197
+ [Turn on live data](#turn-on-live-data); `GREENPICK_CARBON_SOURCE` selects the source (`auto` routes
198
+ US→EIA, EU→ENTSO-E, else mock).
198
199
 
199
200
  The data layer is a swappable repository (`MOCK` by default, works offline). Nothing here invents
200
201
  numbers: the LLM writes the *rationale*, the code supplies the *figures*.
@@ -163,8 +163,9 @@ See `docs/user-questions-backlog.md`.
163
163
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
164
164
  - Latency: great-circle distance from region coordinates to the user's location.
165
165
 
166
- Set `EIA_API_KEY` and `ENTSOE_TOKEN` in `.env` (both free) for live carbon; `GREENPICK_CARBON_SOURCE`
167
- selects the source (`auto` routes US→EIA, EU→ENTSO-E, else mock).
166
+ Set `EIA_API_KEY` and `ENTSOE_TOKEN` (both free) for live carbon — see
167
+ [Turn on live data](#turn-on-live-data); `GREENPICK_CARBON_SOURCE` selects the source (`auto` routes
168
+ US→EIA, EU→ENTSO-E, else mock).
168
169
 
169
170
  The data layer is a swappable repository (`MOCK` by default, works offline). Nothing here invents
170
171
  numbers: the LLM writes the *rationale*, the code supplies the *figures*.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -4,4 +4,4 @@ Greener cloud-region choices with honest carbon + cost + latency tradeoffs,
4
4
  delivered inside the vibe-coding loop. Directional guidance, not audited accounting.
5
5
  """
6
6
 
7
- __version__ = "0.1.2"
7
+ __version__ = "0.1.4"
@@ -6,8 +6,7 @@
6
6
  "us-west-2": { "name": "Oregon", "lat": 45.87, "lon": -119.69, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" },
7
7
  "eu-west-1": { "name": "Ireland", "lat": 53.41, "lon": -8.24, "zone": "IE", "entsoe_eic": "10Y1001A1001A59C", "jurisdiction": "EEA" },
8
8
  "eu-north-1": { "name": "Stockholm", "lat": 59.33, "lon": 18.07, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" },
9
- "ap-southeast-2": { "name": "Sydney", "lat": -33.87, "lon": 151.21, "zone": "AU-NSW", "jurisdiction": "AU" },
10
- "us-east-nyc": { "name": "New York (NYISO)", "lat": 40.71, "lon": -74.01, "zone": "US-NY-NYIS", "eia_ba": "NYIS", "jurisdiction": "US" }
9
+ "ap-southeast-2": { "name": "Sydney", "lat": -33.87, "lon": 151.21, "zone": "AU-NSW", "jurisdiction": "AU" }
11
10
  },
12
11
  "gcp": {
13
12
  "us-central1": { "name": "Iowa", "lat": 41.26, "lon": -95.86, "zone": "US-MIDW-MISO", "eia_ba": "MISO", "jurisdiction": "US" },
@@ -164,6 +164,7 @@ class TrainingCarbonAdvice(BaseModel):
164
164
  gpu_name: str
165
165
  gpu_count: int
166
166
  gpu_hours: float # total accelerator-hours for the run
167
+ wall_clock_hours: Optional[float] = None # ~gpu_hours / gpu_count (GPUs run in parallel)
167
168
  utilization: float
168
169
  est_energy_kwh: float
169
170
  # Placement:
@@ -314,7 +314,9 @@ async def estimate_training_carbon(
314
314
  top_n_regions: int = 5,
315
315
  ) -> dict:
316
316
  """Carbon of a GPU training run, with greener WHERE (region) and WHEN (hours) guidance.
317
- Give the GPU (e.g. 'a100', 'h100', 'l4'), how many, and total GPU-hours for the run. Omit
317
+ Give the GPU (e.g. 'a100', 'h100', 'l4'), how many (gpu_count), and gpu_hours = TOTAL
318
+ accelerator-hours across ALL GPUs — e.g. 8 GPUs running for 10 hours is gpu_hours=80, not 10.
319
+ The output echoes wall_clock_hours (~gpu_hours / gpu_count) for reporting. Omit
318
320
  `region` to rank regions greenest-first; pass one to score just that region. Training is both
319
321
  portable and deferrable, so this combines region choice with cleanest-hours timing. Directional
320
322
  (TDP-based energy); GPU availability per region is NOT checked - verify your card is offered there.
@@ -129,6 +129,10 @@ async def data_locality_check(
129
129
  warning = (f"Compute and data are separated {sep}: ~{monthly_cross_boundary_gb:,.0f} GB/mo would "
130
130
  f"cross the boundary, costing {cost_txt}, plus ~{transfer_kg:.1f} kg CO2/mo of extra "
131
131
  f"transfer. A greener-region move can be undone by this.")
132
+ if verdict is None:
133
+ # Point the caller at the optional input that turns this into a net win/backfire verdict.
134
+ warning += (" For a net verdict, pass carbon_saving_kg_co2_from_move (the kg CO2/mo the move "
135
+ "saves, e.g. the difference between estimate_workload_carbon in the two regions).")
132
136
 
133
137
  if severity == "high":
134
138
  rec = ("High exposure. Co-locate compute with the data, or move the data to the greener "
@@ -22,15 +22,35 @@ _HEADROOM = 1.4
22
22
  _MIN_SAVING_PCT = 10.0
23
23
 
24
24
 
25
- def _rightsized_dimension(provisioned: float, utilization: Optional[float]) -> float:
26
- """Target = observed usage x headroom, floored at a small minimum, never above provisioned
27
- (right-sizing only shrinks here). If utilisation is unknown, leave the dimension unchanged."""
25
+ def _needed(provisioned: float, utilization: Optional[float]) -> float:
26
+ """Capacity actually needed = observed usage x headroom. If utilisation is unknown, assume the
27
+ dimension is needed in full (we only right-size what the user told us about)."""
28
28
  if utilization is None:
29
29
  return provisioned
30
30
  u = max(0.0, min(1.0, utilization))
31
- target = provisioned * u * _HEADROOM
32
- target = max(0.5, target) # floor: don't suggest sub-0.5 vCPU/GB
33
- return min(provisioned, round(target, 1))
31
+ return provisioned * u * _HEADROOM
32
+
33
+
34
+ # Standard instance shapes shared by AWS / GCP / Azure: vCPU counts step in powers of two (plus the
35
+ # common 48/96/192), and memory comes at ~2 GB/vCPU (compute-optimised), ~4 GB/vCPU (general
36
+ # purpose) or ~8 GB/vCPU (memory-optimised). Recommending only these shapes means the answer is a
37
+ # size you can actually buy, and the saving is priced on that size — not on a fractional ideal.
38
+ _VCPU_SIZES = (1, 2, 4, 8, 16, 32, 48, 64, 96, 128, 192)
39
+ _GB_PER_VCPU = (2, 4, 8)
40
+
41
+
42
+ def _standard_size(need_vcpu: float, need_ram: float,
43
+ prov_vcpu: float, prov_ram: float) -> tuple[float, float]:
44
+ """Smallest standard shape covering both needs, never larger than what's provisioned
45
+ (right-sizing only shrinks or holds). Falls back to the provisioned size if nothing fits."""
46
+ for v in _VCPU_SIZES:
47
+ if v < need_vcpu or v > prov_vcpu:
48
+ continue
49
+ for ratio in _GB_PER_VCPU:
50
+ ram = v * ratio
51
+ if ram >= need_ram and ram <= prov_ram:
52
+ return float(v), float(ram)
53
+ return prov_vcpu, prov_ram
34
54
 
35
55
 
36
56
  async def rightsizing_check(
@@ -43,8 +63,9 @@ async def rightsizing_check(
43
63
  ram_utilization: Optional[float] = None,
44
64
  hours_per_month: float = 730,
45
65
  ) -> RightsizingAdvice:
46
- rec_vcpu = _rightsized_dimension(vcpu, cpu_utilization)
47
- rec_ram = _rightsized_dimension(ram_gb, ram_utilization)
66
+ rec_vcpu, rec_ram = _standard_size(
67
+ _needed(vcpu, cpu_utilization), _needed(ram_gb, ram_utilization), vcpu, ram_gb,
68
+ )
48
69
 
49
70
  now_wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=hours_per_month)
50
71
  rs_wl = Workload(vcpu=rec_vcpu, ram_gb=rec_ram, hours_per_month=hours_per_month)
@@ -66,8 +87,9 @@ async def rightsizing_check(
66
87
  usd_txt = f"~${usd_saving:,.0f}/mo" if usd_saving is not None else "cost"
67
88
  rationale = (
68
89
  f"Over-provisioned: at ~{cpu_utilization*100:.0f}% CPU use, {vcpu:g} vCPU / {ram_gb:g} GB "
69
- f"is bigger than you need. Right-size to ~{rec_vcpu:g} vCPU / {rec_ram:g} GB (keeps ~40% "
70
- f"headroom) to cut ~{saving_pct:.0f}% - {usd_txt} AND ~{kg_saving:.1f} kg CO2/mo. "
90
+ f"is bigger than you need. Right-size to {rec_vcpu:g} vCPU / {rec_ram:g} GB (the smallest "
91
+ f"standard size that keeps ~40% headroom) to cut ~{saving_pct:.0f}% - {usd_txt} AND "
92
+ f"~{kg_saving:.1f} kg CO2/mo. "
71
93
  f"Same work, less waste: the cost and carbon win here are the same action.")
72
94
  else:
73
95
  rationale = (
@@ -88,6 +110,8 @@ async def rightsizing_check(
88
110
  rationale=rationale,
89
111
  limitations=("Directional, design-time estimate from the utilisation YOU report — not a "
90
112
  "reading of your running fleet, and not a quote. Keeps ~40% headroom above "
91
- "observed peak; validate against real load before resizing production. Cost "
113
+ "observed peak, rounded up to a standard shape (power-of-two vCPU at ~2/4/8 GB "
114
+ "per vCPU) — confirm your provider offers that size in the family you need; "
115
+ "validate against real load before resizing production. Cost "
92
116
  "uses the dated static table; carbon = power model x live grid intensity."),
93
117
  )
@@ -159,8 +159,13 @@ async def serving_pattern(
159
159
  recommended, rationale = _decide(single_plan, per_hub, multi_wlat, multi_blended,
160
160
  len(distinct_regions))
161
161
 
162
+ # Echo hubs with the coordinates actually used (named hubs like "eu" resolve to representative
163
+ # coords), so the output never shows a misleading 0,0 for a hub that was resolved by name.
164
+ resolved_hubs = [ServingHub(name=h.name, lat=pt.lat, lon=pt.lon, share=h.share)
165
+ for h, pt in zip(hubs, points)]
166
+
162
167
  return ServingPatternAdvice(
163
- hubs=hubs, single_region=single_plan, multi_region=per_hub,
168
+ hubs=resolved_hubs, single_region=single_plan, multi_region=per_hub,
164
169
  multi_region_weighted_latency_ms=multi_wlat,
165
170
  multi_region_blended_carbon_g_co2_per_kwh=multi_blended,
166
171
  recommended_pattern=recommended, data_sync_egress_note=data_sync_note,
@@ -89,6 +89,9 @@ async def estimate_training_carbon(
89
89
 
90
90
  base = TrainingCarbonAdvice(
91
91
  gpu=gpu_key, gpu_name=spec["name"], gpu_count=gpu_count, gpu_hours=gpu_hours,
92
+ # Echo the run's duration so callers report it rather than re-deriving it (a common slip
93
+ # is reading gpu_hours as per-GPU and multiplying by gpu_count again).
94
+ wall_clock_hours=round(gpu_hours / max(1, gpu_count), 1),
92
95
  utilization=util, est_energy_kwh=energy, limitations=LIMITATIONS,
93
96
  )
94
97
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
5
5
  Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
6
6
  License-Expression: MIT
@@ -193,8 +193,9 @@ See `docs/user-questions-backlog.md`.
193
193
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
194
194
  - Latency: great-circle distance from region coordinates to the user's location.
195
195
 
196
- Set `EIA_API_KEY` and `ENTSOE_TOKEN` in `.env` (both free) for live carbon; `GREENPICK_CARBON_SOURCE`
197
- selects the source (`auto` routes US→EIA, EU→ENTSO-E, else mock).
196
+ Set `EIA_API_KEY` and `ENTSOE_TOKEN` (both free) for live carbon — see
197
+ [Turn on live data](#turn-on-live-data); `GREENPICK_CARBON_SOURCE` selects the source (`auto` routes
198
+ US→EIA, EU→ENTSO-E, else mock).
198
199
 
199
200
  The data layer is a swappable repository (`MOCK` by default, works offline). Nothing here invents
200
201
  numbers: the LLM writes the *rationale*, the code supplies the *figures*.
@@ -179,20 +179,20 @@ from greenpick.data.eia_repository import EIARepository
179
179
 
180
180
  @pytest.mark.asyncio
181
181
  async def test_eia_mix_to_carbon_intensity(monkeypatch):
182
- # NYISO latest hour: 1000 MWh gas (490) + 1000 MWh nuclear (12) -> weighted mean = 251 gCO2/kWh.
182
+ # PJM latest hour: 1000 MWh gas (490) + 1000 MWh nuclear (12) -> weighted mean = 251 gCO2/kWh.
183
183
  def handler(request: httpx.Request) -> httpx.Response:
184
- assert request.url.params.get("facets[respondent][]") == "NYIS"
184
+ assert request.url.params.get("facets[respondent][]") == "PJM"
185
185
  return httpx.Response(200, json={"response": {"data": [
186
- {"period": "2026-10-11T18", "respondent": "NYIS", "fueltype": "NG", "value": "1000"},
187
- {"period": "2026-10-11T18", "respondent": "NYIS", "fueltype": "NUC", "value": "1000"},
188
- {"period": "2026-10-11T17", "respondent": "NYIS", "fueltype": "NG", "value": "9999"}, # older hour, ignored
186
+ {"period": "2026-10-11T18", "respondent": "PJM", "fueltype": "NG", "value": "1000"},
187
+ {"period": "2026-10-11T18", "respondent": "PJM", "fueltype": "NUC", "value": "1000"},
188
+ {"period": "2026-10-11T17", "respondent": "PJM", "fueltype": "NG", "value": "9999"}, # older hour, ignored
189
189
  ]}})
190
190
 
191
191
  orig = httpx.AsyncClient
192
192
  monkeypatch.setattr(httpx, "AsyncClient",
193
193
  lambda *a, **k: orig(transport=_mock_transport(handler)))
194
194
  repo = EIARepository(api_key="key")
195
- r = await repo.carbon_now("aws", "us-east-nyc") # maps to BA NYIS
195
+ r = await repo.carbon_now("aws", "us-east-1") # maps to BA PJM
196
196
  assert r.source == "eia"
197
197
  assert r.quality == "live"
198
198
  assert abs(r.g_co2_per_kwh - 251.0) < 0.5 # (490+12)/2
@@ -209,7 +209,7 @@ async def test_eia_non_us_region_falls_back_to_mock():
209
209
  @pytest.mark.asyncio
210
210
  async def test_eia_no_key_uses_mock():
211
211
  repo = EIARepository(api_key="")
212
- r = await repo.carbon_now("aws", "us-east-nyc")
212
+ r = await repo.carbon_now("aws", "us-east-1")
213
213
  assert r.quality == "mock"
214
214
 
215
215
 
@@ -601,7 +601,7 @@ async def test_serving_concentrated_users_prefer_single_region():
601
601
  assert adv.single_region is not None
602
602
  assert adv.recommended_pattern == "single_region"
603
603
  # Single region should be a US region (nearest the only hub).
604
- assert adv.single_region.region in {"us-east-1", "us-west-2", "us-east-nyc", "us-central1",
604
+ assert adv.single_region.region in {"us-east-1", "us-west-2", "us-central1",
605
605
  "us-west1", "eastus"}
606
606
 
607
607
 
@@ -982,7 +982,7 @@ async def test_cleanest_time_of_day_averages_across_days(monkeypatch):
982
982
  monkeypatch.setattr(httpx, "AsyncClient",
983
983
  lambda *a, **k: orig(transport=_mock_transport(handler)))
984
984
  repo = EIARepository(api_key="k")
985
- adv = await scheduling_service.cleanest_time_of_day(repo, "aws", "us-east-nyc")
985
+ adv = await scheduling_service.cleanest_time_of_day(repo, "aws", "us-east-1")
986
986
  assert adv.days_analyzed == 3
987
987
  assert adv.cleanest_hour_of_day == 2 # nuclear-heavy hour, every day
988
988
  assert adv.dirtiest_hour_of_day == 18 # gas-heavy hour, every day
@@ -1072,7 +1072,74 @@ async def test_cleanest_hours_eia_multihour(monkeypatch):
1072
1072
  monkeypatch.setattr(httpx, "AsyncClient",
1073
1073
  lambda *a, **k: orig(transport=_mock_transport(handler)))
1074
1074
  repo = EIARepository(api_key="k")
1075
- hist = await repo.carbon_history("aws", "us-east-nyc")
1075
+ hist = await repo.carbon_history("aws", "us-east-1")
1076
1076
  periods = {h.period: h.g_co2_per_kwh for h in hist}
1077
1077
  assert set(periods) == {"2026-10-12T18", "2026-10-12T19"}
1078
1078
  assert periods["2026-10-12T18"] < periods["2026-10-12T19"] # nuclear-heavy hour cleaner
1079
+
1080
+
1081
+ # --- 0.1.4 regressions: findings from outside-agent testing ---
1082
+
1083
+ @pytest.mark.asyncio
1084
+ async def test_rightsizing_recommends_a_standard_buyable_size():
1085
+ from greenpick.services import rightsizing_service
1086
+ repo = MockCarbonRepository()
1087
+ # 8 vCPU / 32 GB at 15% CPU, 20% RAM: the raw fit is ~1.7 vCPU / ~9 GB, which no cloud sells.
1088
+ adv = await rightsizing_service.rightsizing_check(
1089
+ repo, "aws", "us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15, ram_utilization=0.20,
1090
+ )
1091
+ assert adv.is_oversized
1092
+ assert (adv.recommended_vcpu, adv.recommended_ram_gb) == (2.0, 16.0) # smallest shape that fits
1093
+ assert adv.recommended_ram_gb / adv.recommended_vcpu in (2, 4, 8)
1094
+ assert "standard size" in adv.rationale
1095
+
1096
+
1097
+ @pytest.mark.asyncio
1098
+ async def test_rightsizing_unknown_ram_keeps_ram_and_picks_a_real_shape():
1099
+ from greenpick.services import rightsizing_service
1100
+ repo = MockCarbonRepository()
1101
+ # RAM utilisation not given -> keep 32 GB; 2 vCPU / 32 GB isn't a standard shape, so 4 / 32.
1102
+ adv = await rightsizing_service.rightsizing_check(
1103
+ repo, "aws", "us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.18,
1104
+ )
1105
+ assert (adv.recommended_vcpu, adv.recommended_ram_gb) == (4.0, 32.0)
1106
+
1107
+
1108
+ @pytest.mark.asyncio
1109
+ async def test_training_echoes_wall_clock_hours():
1110
+ from greenpick.services import training_service
1111
+ repo = MockCarbonRepository()
1112
+ adv = await training_service.estimate_training_carbon(
1113
+ repo, gpu="a100", gpu_count=8, gpu_hours=150, region="eu-north-1", provider="aws",
1114
+ )
1115
+ # gpu_hours is TOTAL across GPUs: 150 GPU-hours on 8 GPUs runs ~18.8 h, not 150 h.
1116
+ assert adv.wall_clock_hours == 18.8
1117
+
1118
+
1119
+ @pytest.mark.asyncio
1120
+ async def test_egress_points_to_saving_input_only_when_missing():
1121
+ from greenpick.services import locality_service
1122
+ repo = MockCarbonRepository()
1123
+ without = await locality_service.data_locality_check(
1124
+ repo, "aws", "us-west-2", "aws", "us-east-1", 500,
1125
+ )
1126
+ assert "carbon_saving_kg_co2_from_move" in without.warning
1127
+ with_saving = await locality_service.data_locality_check(
1128
+ repo, "aws", "us-west-2", "aws", "us-east-1", 500, carbon_saving_kg_co2_from_move=10.0,
1129
+ )
1130
+ assert with_saving.net_carbon_verdict is not None
1131
+ assert "carbon_saving_kg_co2_from_move" not in with_saving.warning
1132
+
1133
+
1134
+ @pytest.mark.asyncio
1135
+ async def test_serving_echoes_resolved_hub_coordinates():
1136
+ from greenpick.services import serving_service
1137
+ repo = MockCarbonRepository()
1138
+ hubs = [ServingHub(name="us", lat=0, lon=0, share=0.5), ServingHub(name="eu", lat=0, lon=0, share=0.5)]
1139
+ adv = await serving_service.serving_pattern(repo, hubs, Workload(vcpu=1, ram_gb=2))
1140
+ assert all((h.lat, h.lon) != (0.0, 0.0) for h in adv.hubs)
1141
+
1142
+
1143
+ def test_catalog_has_no_invented_aws_new_york_region():
1144
+ from greenpick.data.static_data import regions
1145
+ assert "us-east-nyc" not in regions()["aws"]
File without changes
File without changes