greenpick 0.1.6__tar.gz → 0.1.7__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 (49) hide show
  1. {greenpick-0.1.6/src/greenpick.egg-info → greenpick-0.1.7}/PKG-INFO +4 -2
  2. {greenpick-0.1.6 → greenpick-0.1.7}/README.md +3 -1
  3. {greenpick-0.1.6 → greenpick-0.1.7}/pyproject.toml +1 -1
  4. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/models.py +5 -2
  6. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/server.py +1 -1
  7. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/rightsizing_service.py +11 -4
  8. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/scheduling_service.py +26 -8
  9. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/training_service.py +7 -5
  10. {greenpick-0.1.6 → greenpick-0.1.7/src/greenpick.egg-info}/PKG-INFO +4 -2
  11. {greenpick-0.1.6 → greenpick-0.1.7}/tests/test_services.py +64 -0
  12. {greenpick-0.1.6 → greenpick-0.1.7}/LICENSE +0 -0
  13. {greenpick-0.1.6 → greenpick-0.1.7}/setup.cfg +0 -0
  14. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/__main__.py +0 -0
  15. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/config.py +0 -0
  16. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/__init__.py +0 -0
  17. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/_net.py +0 -0
  18. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/auto_repository.py +0 -0
  19. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/eia_repository.py +0 -0
  20. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/electricitymaps_repository.py +0 -0
  21. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/emission_factors.json +0 -0
  22. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/entsoe_repository.py +0 -0
  23. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/gpu_power.json +0 -0
  24. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/inference_energy.json +0 -0
  25. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/instance_families.json +0 -0
  26. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/mock_repository.py +0 -0
  27. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/network.json +0 -0
  28. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/power.json +0 -0
  29. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/pricing.json +0 -0
  30. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/regions.json +0 -0
  31. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/repository.py +0 -0
  32. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/data/static_data.py +0 -0
  33. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/__init__.py +0 -0
  34. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/carbon_service.py +0 -0
  35. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/cost_service.py +0 -0
  36. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/forecast_service.py +0 -0
  37. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/inference_service.py +0 -0
  38. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/instance_service.py +0 -0
  39. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/latency_service.py +0 -0
  40. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/locality_service.py +0 -0
  41. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/metric_scaffold_service.py +0 -0
  42. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/recommender.py +0 -0
  43. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/residency_service.py +0 -0
  44. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick/services/serving_service.py +0 -0
  45. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick.egg-info/SOURCES.txt +0 -0
  46. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick.egg-info/dependency_links.txt +0 -0
  47. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick.egg-info/entry_points.txt +0 -0
  48. {greenpick-0.1.6 → greenpick-0.1.7}/src/greenpick.egg-info/requires.txt +0 -0
  49. {greenpick-0.1.6 → greenpick-0.1.7}/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.6
3
+ Version: 0.1.7
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
@@ -260,6 +260,7 @@ environment variables to it:
260
260
 
261
261
  ```python
262
262
  import sys
263
+ # "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
263
264
  from mcp import ClientSession, StdioServerParameters
264
265
  from mcp.client.stdio import stdio_client
265
266
 
@@ -273,7 +274,8 @@ params = StdioServerParameters(
273
274
  },
274
275
  )
275
276
 
276
- # In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
277
+ # Keep errlog=: in Jupyter it must be a real file, because the notebook's stderr has no fileno()
278
+ # (without it you get "UnsupportedOperation: fileno").
277
279
  with open("greenpick-server.log", "w") as errlog:
278
280
  async with stdio_client(params, errlog=errlog) as (read, write):
279
281
  async with ClientSession(read, write) as session:
@@ -230,6 +230,7 @@ environment variables to it:
230
230
 
231
231
  ```python
232
232
  import sys
233
+ # "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
233
234
  from mcp import ClientSession, StdioServerParameters
234
235
  from mcp.client.stdio import stdio_client
235
236
 
@@ -243,7 +244,8 @@ params = StdioServerParameters(
243
244
  },
244
245
  )
245
246
 
246
- # In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
247
+ # Keep errlog=: in Jupyter it must be a real file, because the notebook's stderr has no fileno()
248
+ # (without it you get "UnsupportedOperation: fileno").
247
249
  with open("greenpick-server.log", "w") as errlog:
248
250
  async with stdio_client(params, errlog=errlog) as (read, write):
249
251
  async with ClientSession(read, write) as session:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.1.6"
3
+ version = "0.1.7"
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,7 +4,7 @@ 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.6"
7
+ __version__ = "0.1.7"
8
8
 
9
9
  # httpx logs every request URL at INFO, and our live sources pass their API key / token as a URL
10
10
  # query parameter — so at the default log level the secret would land in the MCP client's logs.
@@ -70,7 +70,8 @@ class ForecastAdvice(BaseModel):
70
70
  class CleanestWindow(BaseModel):
71
71
  """The lowest-average contiguous run of N hours — for timing a job of known duration."""
72
72
  window_hours: int
73
- start_period: str # the hour the window begins (start your job here)
73
+ start_hour_of_day: Optional[int] = None # 0..23 — the advice: start around this hour each time
74
+ start_period: str # when that window began in the recent data (evidence, not a date to use)
74
75
  end_period: str # the last hour in the window
75
76
  avg_g_co2_per_kwh: float # mean intensity across the window
76
77
  hours: list[HourlyCarbon] = [] # the per-hour breakdown of the chosen window
@@ -224,7 +225,9 @@ class RightsizingAdvice(BaseModel):
224
225
  carbon_now_kg_per_month: Optional[float] = None
225
226
  carbon_rightsized_kg_per_month: Optional[float] = None
226
227
  monthly_kg_co2_saving: Optional[float] = None
227
- saving_pct: Optional[float] = None
228
+ cost_saving_pct: Optional[float] = None # % off the monthly bill
229
+ carbon_saving_pct: Optional[float] = None # % off monthly kg CO2
230
+ saving_pct: Optional[float] = None # = carbon_saving_pct; kept for compatibility
228
231
  carbon_source: Optional[str] = None
229
232
  carbon_quality: Optional[str] = None
230
233
  rationale: str = ""
@@ -35,7 +35,7 @@ serving_pattern_advice; carbon_metric_scaffold for an app to track its own estim
35
35
  Before recommending a region move, run data_locality_egress_check: egress can erase the win. Mention \
36
36
  any residency_note.
37
37
  Every carbon figure carries source and quality fields. Report 'mock', 'cached' or 'historical' values \
38
- as such, never as live readings. If a call fails or output is missing, say so rather than describing \
38
+ as such, never as live readings — including figures derived from them, such as a kg CO2 saving. If a call fails or output is missing, say so rather than describing \
39
39
  what the tool would return. GreenPick estimates; it does not measure running systems or report model \
40
40
  accuracy. Use explain_methodology_and_limits when asked how a number was produced.\
41
41
  """
@@ -83,13 +83,19 @@ async def rightsizing_check(
83
83
 
84
84
  is_oversized = saving_pct is not None and saving_pct >= _MIN_SAVING_PCT
85
85
 
86
+ # Separate, labelled percentages: cost and carbon shrink by different fractions (price and power
87
+ # scale differently with vCPU vs RAM), so one unlabelled % beside a dollar figure misleads.
88
+ cost_pct = (round(usd_saving / cost_now * 100, 1)
89
+ if usd_saving is not None and cost_now else None)
90
+
86
91
  if is_oversized:
87
- usd_txt = f"~${usd_saving:,.0f}/mo" if usd_saving is not None else "cost"
92
+ cost_txt = (f"~{cost_pct:.0f}% cheaper (~${usd_saving:,.0f}/mo saved)"
93
+ if cost_pct is not None else "a lower bill")
88
94
  rationale = (
89
95
  f"Over-provisioned: at ~{cpu_utilization*100:.0f}% CPU use, {vcpu:g} vCPU / {ram_gb:g} GB "
90
96
  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. "
97
+ f"standard size that keeps ~40% headroom): {cost_txt} AND ~{saving_pct:.0f}% less carbon "
98
+ f"(~{kg_saving:.1f} kg CO2/mo, {reading.quality} grid data). "
93
99
  f"Same work, less waste: the cost and carbon win here are the same action.")
94
100
  else:
95
101
  rationale = (
@@ -105,7 +111,8 @@ async def rightsizing_check(
105
111
  cost_now_usd_per_month=cost_now, cost_rightsized_usd_per_month=cost_rs,
106
112
  monthly_usd_saving=usd_saving,
107
113
  carbon_now_kg_per_month=carbon_now, carbon_rightsized_kg_per_month=carbon_rs,
108
- monthly_kg_co2_saving=kg_saving, saving_pct=saving_pct,
114
+ monthly_kg_co2_saving=kg_saving,
115
+ cost_saving_pct=cost_pct, carbon_saving_pct=saving_pct, saving_pct=saving_pct,
109
116
  carbon_source=reading.source, carbon_quality=reading.quality,
110
117
  rationale=rationale,
111
118
  limitations=("Directional, design-time estimate from the utilisation YOU report — not a "
@@ -37,6 +37,22 @@ def _hour_index(period: str) -> Optional[int]:
37
37
  return int(dt.timestamp() // 3600) # absolute hours since epoch
38
38
 
39
39
 
40
+ def hour_of_day(period: str) -> Optional[int]:
41
+ """0..23 hour-of-day for a period label (UTC for live ISO timestamps; mock 'H15' -> 15)."""
42
+ idx = _hour_index(period)
43
+ return None if idx is None else idx % 24
44
+
45
+
46
+ def clock_label(period: str) -> str:
47
+ """Turn a past period into advice you can act on: '15:00 UTC' (live) or '15:00' (mock hours
48
+ carry no timezone). Recent history says WHEN IN THE DAY is cleanest — the date it happened on
49
+ is evidence, not a start date, so it's never the headline."""
50
+ hod = hour_of_day(period)
51
+ if hod is None:
52
+ return period
53
+ return f"{hod:02d}:00" if period.strip().startswith("H") else f"{hod:02d}:00 UTC"
54
+
55
+
40
56
  def _best_and_worst_window(series: list[HourlyCarbon], window_hours: int
41
57
  ) -> tuple[Optional[list[HourlyCarbon]], Optional[float]]:
42
58
  """Slide a window of `window_hours` over the CHRONOLOGICALLY ordered series, considering only
@@ -114,20 +130,22 @@ async def cleanest_hours(
114
130
  win_hours, win_reduction = _best_and_worst_window(history, window_hours)
115
131
  if win_hours:
116
132
  avg = round(sum(h.g_co2_per_kwh for h in win_hours) / len(win_hours), 1)
133
+ start = win_hours[0].period
117
134
  advice.window = CleanestWindow(
118
- window_hours=window_hours,
119
- start_period=win_hours[0].period, end_period=win_hours[-1].period,
135
+ window_hours=window_hours, start_hour_of_day=hour_of_day(start),
136
+ start_period=start, end_period=win_hours[-1].period,
120
137
  avg_g_co2_per_kwh=avg, hours=win_hours,
121
138
  )
122
139
  advice.window_reduction_pct = win_reduction
140
+ seen = f"(the cleanest {window_hours}h window in the recent data began {start})"
123
141
  if win_reduction is not None and win_reduction >= 10:
124
- advice.note = (f"For a ~{window_hours}h job, start it at {win_hours[0].period}: that's "
125
- f"the cleanest {window_hours}h window (avg ~{avg:.0f} gCO2/kWh), "
126
- f"~{win_reduction:.0f}% cleaner than the worst {window_hours}h window.")
142
+ advice.note = (f"For a ~{window_hours}h job, start around {clock_label(start)} "
143
+ f"{seen}: avg ~{avg:.0f} gCO2/kWh, ~{win_reduction:.0f}% cleaner than "
144
+ f"the worst {window_hours}h window. A recent pattern, not a forecast.")
127
145
  else:
128
- advice.note = (f"For a ~{window_hours}h job, the cleanest window starts at "
129
- f"{win_hours[0].period} (avg ~{avg:.0f} gCO2/kWh), but the grid is "
130
- f"fairly flat — timing won't help much here.")
146
+ advice.note = (f"For a ~{window_hours}h job, the cleanest start is around "
147
+ f"{clock_label(start)} {seen}, avg ~{avg:.0f} gCO2/kWh, but the grid "
148
+ f"is fairly flat — timing won't help much here.")
131
149
  else:
132
150
  advice.note = (f"Not enough contiguous history to find a {window_hours}h window; "
133
151
  f"showing cleanest individual hours instead. " + advice.note)
@@ -170,12 +170,14 @@ async def estimate_training_carbon(
170
170
 
171
171
  if advice.window and advice.window_reduction_pct and advice.window_reduction_pct >= 10:
172
172
  w = advice.window
173
- time_txt = (f" It runs ~{wall_clock_h}h wall-clock; the cleanest {w.window_hours}h window to "
174
- f"start is {w.start_period} (avg ~{w.avg_g_co2_per_kwh:.0f} gCO2/kWh), "
175
- f"~{advice.window_reduction_pct:.0f}% cleaner than the worst same-length window - "
176
- f"training is deferrable, so this stacks on the region choice.")
173
+ time_txt = (f" It runs ~{wall_clock_h}h wall-clock; start it around "
174
+ f"{scheduling_service.clock_label(w.start_period)} (the cleanest {w.window_hours}h "
175
+ f"window in the recent data began {w.start_period}, avg ~{w.avg_g_co2_per_kwh:.0f} "
176
+ f"gCO2/kWh), ~{advice.window_reduction_pct:.0f}% cleaner than the worst same-length "
177
+ f"window - training is deferrable, so this stacks on the region choice. "
178
+ f"A recent pattern, not a forecast.")
177
179
  elif advice.potential_reduction_pct and advice.potential_reduction_pct >= 10 and advice.cleanest:
178
- best = advice.cleanest[0].period
180
+ best = scheduling_service.clock_label(advice.cleanest[0].period)
179
181
  time_txt = (f" Timing it to the cleanest hours in that region (around {best}) cuts a further "
180
182
  f"~{advice.potential_reduction_pct:.0f}% vs the dirtiest hour - training is "
181
183
  f"deferrable, so this stacks on the region choice.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.1.6
3
+ Version: 0.1.7
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
@@ -260,6 +260,7 @@ environment variables to it:
260
260
 
261
261
  ```python
262
262
  import sys
263
+ # "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
263
264
  from mcp import ClientSession, StdioServerParameters
264
265
  from mcp.client.stdio import stdio_client
265
266
 
@@ -273,7 +274,8 @@ params = StdioServerParameters(
273
274
  },
274
275
  )
275
276
 
276
- # In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
277
+ # Keep errlog=: in Jupyter it must be a real file, because the notebook's stderr has no fileno()
278
+ # (without it you get "UnsupportedOperation: fileno").
277
279
  with open("greenpick-server.log", "w") as errlog:
278
280
  async with stdio_client(params, errlog=errlog) as (read, write):
279
281
  async with ClientSession(read, write) as session:
@@ -1251,3 +1251,67 @@ def test_server_sends_instructions_that_lead_with_rightsizing():
1251
1251
  named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", text)) - {"wall_clock_hours", "gpu_hours", "residency_note"}
1252
1252
  registered = {t.name for t in asyncio.run(server.mcp.list_tools())}
1253
1253
  assert named <= registered, named - registered
1254
+
1255
+
1256
+ # --- 0.1.7: labelled percentages; start advice as a time of day, not a past date ---
1257
+
1258
+ @pytest.mark.asyncio
1259
+ async def test_rightsizing_labels_cost_and_carbon_percentages_separately():
1260
+ from greenpick.services import rightsizing_service
1261
+ repo = MockCarbonRepository()
1262
+ adv = await rightsizing_service.rightsizing_check(
1263
+ repo, "aws", "us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15,
1264
+ )
1265
+ # Cost % is the bill saving; carbon % is the kg saving. They differ, so both are reported.
1266
+ assert adv.cost_saving_pct == round(adv.monthly_usd_saving / adv.cost_now_usd_per_month * 100, 1)
1267
+ assert adv.carbon_saving_pct == round(adv.monthly_kg_co2_saving / adv.carbon_now_kg_per_month * 100, 1)
1268
+ assert adv.cost_saving_pct != adv.carbon_saving_pct
1269
+ assert adv.saving_pct == adv.carbon_saving_pct # compatibility field unchanged
1270
+ assert f"~{adv.cost_saving_pct:.0f}% cheaper" in adv.rationale
1271
+ assert f"~{adv.carbon_saving_pct:.0f}% less carbon" in adv.rationale
1272
+ assert "mock grid data" in adv.rationale # derived carbon carries its quality
1273
+
1274
+
1275
+ def test_clock_label_turns_past_periods_into_a_time_of_day():
1276
+ from greenpick.services import scheduling_service as ss
1277
+ assert ss.clock_label("2026-10-17T15") == "15:00 UTC"
1278
+ assert ss.clock_label("2026-10-17T15:00Z") == "15:00 UTC"
1279
+ assert ss.clock_label("H02") == "02:00" # mock hours have no timezone
1280
+ assert ss.hour_of_day("2026-10-17T15") == 15
1281
+
1282
+
1283
+ @pytest.mark.asyncio
1284
+ async def test_window_advice_leads_with_time_of_day_not_a_date(monkeypatch):
1285
+ from greenpick.services import scheduling_service as ss
1286
+ from greenpick.models import HourlyCarbon
1287
+
1288
+ class Repo(MockCarbonRepository):
1289
+ async def carbon_history(self, provider, region):
1290
+ # Two past days; 15:00-19:00 is clean on the 17th.
1291
+ out = []
1292
+ for day in ("2026-10-17", "2026-10-18"):
1293
+ for h in range(24):
1294
+ g = 60.0 if (day == "2026-10-17" and 15 <= h <= 19) else 200.0
1295
+ out.append(HourlyCarbon(period=f"{day}T{h:02d}", g_co2_per_kwh=g))
1296
+ return out
1297
+
1298
+ adv = await ss.cleanest_hours(Repo(), "aws", "us-west-2", window_hours=5)
1299
+ assert adv.window.start_hour_of_day == 15
1300
+ assert adv.window.start_period == "2026-10-17T15" # kept, as evidence
1301
+ assert adv.note.startswith("For a ~5h job, start around 15:00 UTC")
1302
+
1303
+
1304
+ @pytest.mark.asyncio
1305
+ async def test_training_rationale_says_start_around_a_time():
1306
+ from greenpick.services import training_service
1307
+ repo = MockCarbonRepository()
1308
+ adv = await training_service.estimate_training_carbon(
1309
+ repo, gpu="a100", gpu_count=8, gpu_hours=40, region="us-east-1", provider="aws",
1310
+ )
1311
+ assert adv.cleanest_window is not None and adv.cleanest_window.start_hour_of_day is not None
1312
+ assert f"start it around {adv.cleanest_window.start_hour_of_day:02d}:00" in adv.rationale
1313
+
1314
+
1315
+ def test_instructions_cover_derived_mock_figures():
1316
+ from greenpick import server
1317
+ assert "derived from them" in server.mcp.instructions
File without changes
File without changes