@nikeandocean/carbon-factor-matcher 0.2.0 → 0.2.1

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.
@@ -1,292 +0,0 @@
1
- """Adapter for ecoinvent Database Overview Excel files.
2
-
3
- Loads activity metadata from the ecoinvent Excel export and converts
4
- them into Factor objects for matching. CO2 values are placeholders (0.0)
5
- until Phase 2 fills them from the actual database.
6
- """
7
-
8
- import logging
9
- from dataclasses import dataclass, field
10
- from pathlib import Path
11
-
12
- from carbon_factor_matcher.adapters.base import FactorAdapter
13
- from carbon_factor_matcher.models import Factor
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- @dataclass
19
- class ImportResult:
20
- """Result of an ecoinvent import operation."""
21
- total_rows: int = 0
22
- imported: int = 0
23
- skipped: int = 0
24
- errors: list[str] = field(default_factory=list)
25
-
26
- @property
27
- def success_rate(self) -> float:
28
- """Calculate import success rate."""
29
- if self.total_rows == 0:
30
- return 0.0
31
- return self.imported / self.total_rows
32
-
33
- # Sheet names for different system models
34
- SYSTEM_MODEL_SHEETS = {
35
- "cut-off": "Cut-Off AO",
36
- "cutoff": "Cut-Off AO",
37
- "undefined": "Undefined AO",
38
- "apos": "APOS AO",
39
- "consequential": "Consequential AO",
40
- "en15804": "EN15804 AO",
41
- }
42
-
43
- # Sectors to skip (not useful for emission factor matching)
44
- SKIP_SECTORS = {
45
- "Waste Treatment & Recycling",
46
- }
47
-
48
-
49
- class EcoinventExcelAdapter(FactorAdapter):
50
- """Load ecoinvent factors from the Database Overview Excel file.
51
-
52
- Args:
53
- excel_path: Path to the ecoinvent Excel file.
54
- system_model: System model to load. One of: "cut-off", "undefined",
55
- "apos", "consequential", "en15804". Default: "cut-off".
56
- sectors: Optional list of sectors to include. If None, includes all.
57
- geographies: Optional list of geography codes to include. If None, includes all.
58
- """
59
-
60
- def __init__(
61
- self,
62
- excel_path: str,
63
- system_model: str = "cut-off",
64
- sectors: list[str] | None = None,
65
- geographies: list[str] | None = None,
66
- ):
67
- self._path = Path(excel_path)
68
- if not self._path.exists():
69
- raise FileNotFoundError(f"Excel file not found: {excel_path}")
70
-
71
- sheet_name = SYSTEM_MODEL_SHEETS.get(system_model.lower())
72
- if sheet_name is None:
73
- raise ValueError(
74
- f"Unknown system model '{system_model}'. "
75
- f"Choose from: {list(SYSTEM_MODEL_SHEETS.keys())}"
76
- )
77
- self._sheet_name = sheet_name
78
- self._sectors = set(sectors) if sectors else None
79
- self._geographies = set(geographies) if geographies else None
80
-
81
- def load(self) -> list[Factor]:
82
- """Load factors from the Excel file."""
83
- try:
84
- import openpyxl
85
- except ImportError:
86
- raise ImportError(
87
- "openpyxl is required for ecoinvent Excel adapter. "
88
- "Install it with: pip install openpyxl"
89
- )
90
-
91
- logger.info(f"Loading ecoinvent from {self._path.name}, sheet={self._sheet_name}")
92
- wb = openpyxl.load_workbook(self._path, read_only=True, data_only=True)
93
-
94
- if self._sheet_name not in wb.sheetnames:
95
- wb.close()
96
- raise ValueError(
97
- f"Sheet '{self._sheet_name}' not found. "
98
- f"Available: {wb.sheetnames}"
99
- )
100
-
101
- ws = wb[self._sheet_name]
102
- headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
103
- col_map = {h: i for i, h in enumerate(headers) if h}
104
-
105
- factors = []
106
- skipped = 0
107
-
108
- for row in ws.iter_rows(min_row=2, values_only=True):
109
- row_list = list(row)
110
-
111
- # Extract fields by header name
112
- activity_uuid = self._get_col(row_list, col_map, "Activity UUID")
113
- activity_name = self._get_col(row_list, col_map, "Activity Name")
114
- geography = self._get_col(row_list, col_map, "Geography")
115
- time_period = self._get_col(row_list, col_map, "Time Period")
116
- sector = self._get_col(row_list, col_map, "Sector")
117
- product_name = self._get_col(row_list, col_map, "Reference Product Name")
118
- unit = self._get_col(row_list, col_map, "Unit")
119
- isic = self._get_col(row_list, col_map, "ISIC Classification")
120
-
121
- # Skip if missing essential fields
122
- if not activity_uuid or not activity_name:
123
- skipped += 1
124
- continue
125
-
126
- # Skip waste/infrastructure sectors
127
- if sector in SKIP_SECTORS:
128
- skipped += 1
129
- continue
130
-
131
- # Apply filters
132
- if self._sectors and sector not in self._sectors:
133
- skipped += 1
134
- continue
135
- if self._geographies and geography not in self._geographies:
136
- skipped += 1
137
- continue
138
-
139
- # Parse year from time period (e.g., "2019 - 2023" → 2019)
140
- source_year = self._parse_year(time_period)
141
-
142
- # Build applicability from product info
143
- applicability = product_name or activity_name
144
-
145
- # Build category from sector + ISIC
146
- category = sector or ""
147
- if isic:
148
- category = f"{sector}/{isic}" if sector else isic
149
-
150
- factor = Factor(
151
- id=activity_uuid,
152
- name=activity_name,
153
- category=category,
154
- value=0.0, # Placeholder — Phase 2 fills CO2 values
155
- unit=unit or "",
156
- geography={"location": geography or ""},
157
- applicability=applicability,
158
- source="ecoinvent 3.10",
159
- source_year=source_year,
160
- raw_data={
161
- "product_name": product_name,
162
- "isic": isic,
163
- "time_period": time_period,
164
- "system_model": self._sheet_name,
165
- },
166
- )
167
- factors.append(factor)
168
-
169
- wb.close()
170
- logger.info(f"Loaded {len(factors)} ecoinvent factors (skipped {skipped})")
171
- return factors
172
-
173
- def load_with_result(self) -> tuple[list[Factor], ImportResult]:
174
- """Load factors and return detailed import result.
175
-
176
- Returns:
177
- Tuple of (factors_list, import_result).
178
- """
179
- try:
180
- import openpyxl
181
- except ImportError:
182
- raise ImportError(
183
- "openpyxl is required for ecoinvent Excel adapter. "
184
- "Install it with: pip install openpyxl"
185
- )
186
-
187
- logger.info(f"Loading ecoinvent from {self._path.name}, sheet={self._sheet_name}")
188
- wb = openpyxl.load_workbook(self._path, read_only=True, data_only=True)
189
-
190
- if self._sheet_name not in wb.sheetnames:
191
- wb.close()
192
- raise ValueError(
193
- f"Sheet '{self._sheet_name}' not found. "
194
- f"Available: {wb.sheetnames}"
195
- )
196
-
197
- ws = wb[self._sheet_name]
198
- headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
199
- col_map = {h: i for i, h in enumerate(headers) if h}
200
-
201
- factors = []
202
- errors = []
203
- total = 0
204
- skipped = 0
205
-
206
- for row in ws.iter_rows(min_row=2, values_only=True):
207
- total += 1
208
- row_list = list(row)
209
-
210
- try:
211
- activity_uuid = self._get_col(row_list, col_map, "Activity UUID")
212
- activity_name = self._get_col(row_list, col_map, "Activity Name")
213
- geography = self._get_col(row_list, col_map, "Geography")
214
- time_period = self._get_col(row_list, col_map, "Time Period")
215
- sector = self._get_col(row_list, col_map, "Sector")
216
- product_name = self._get_col(row_list, col_map, "Reference Product Name")
217
- unit = self._get_col(row_list, col_map, "Unit")
218
- isic = self._get_col(row_list, col_map, "ISIC Classification")
219
-
220
- if not activity_uuid or not activity_name:
221
- skipped += 1
222
- continue
223
-
224
- if sector in SKIP_SECTORS:
225
- skipped += 1
226
- continue
227
-
228
- if self._sectors and sector not in self._sectors:
229
- skipped += 1
230
- continue
231
- if self._geographies and geography not in self._geographies:
232
- skipped += 1
233
- continue
234
-
235
- source_year = self._parse_year(time_period)
236
- applicability = product_name or activity_name
237
- category = sector or ""
238
- if isic:
239
- category = f"{sector}/{isic}" if sector else isic
240
-
241
- factor = Factor(
242
- id=activity_uuid,
243
- name=activity_name,
244
- category=category,
245
- value=0.0,
246
- unit=unit or "",
247
- geography={"location": geography or ""},
248
- applicability=applicability,
249
- source="ecoinvent 3.10",
250
- source_year=source_year,
251
- raw_data={
252
- "product_name": product_name,
253
- "isic": isic,
254
- "time_period": time_period,
255
- "system_model": self._sheet_name,
256
- },
257
- )
258
- factors.append(factor)
259
-
260
- except Exception as e:
261
- errors.append(f"Row {total}: {str(e)}")
262
-
263
- wb.close()
264
-
265
- result = ImportResult(
266
- total_rows=total,
267
- imported=len(factors),
268
- skipped=skipped,
269
- errors=errors,
270
- )
271
- logger.info(f"Loaded {len(factors)} ecoinvent factors (skipped {skipped})")
272
-
273
- return factors, result
274
-
275
- @staticmethod
276
- def _get_col(row: list, col_map: dict, header: str) -> str | None:
277
- """Get a column value by header name."""
278
- idx = col_map.get(header)
279
- if idx is None or idx >= len(row):
280
- return None
281
- val = row[idx]
282
- return str(val).strip() if val else None
283
-
284
- @staticmethod
285
- def _parse_year(time_period: str) -> int:
286
- """Extract start year from time period string like '2019 - 2023'."""
287
- if not time_period:
288
- return 0
289
- try:
290
- return int(time_period[:4])
291
- except (ValueError, IndexError):
292
- return 0
@@ -1,94 +0,0 @@
1
- """HTTP client for the Carbon Factor Matcher remote API."""
2
-
3
- import json
4
- import urllib.request
5
- import urllib.parse
6
- from typing import Optional
7
-
8
- from .models import Factor
9
-
10
-
11
- DEFAULT_API_URL = "https://paddle-webhook.nikeandocean.workers.dev/api"
12
-
13
-
14
- class FactorAPIClient:
15
- """Client for the remote factor data API.
16
-
17
- Args:
18
- base_url: API base URL (default: Cloudflare Worker).
19
- """
20
-
21
- def __init__(self, base_url: str = DEFAULT_API_URL):
22
- self.base_url = base_url.rstrip("/")
23
-
24
- def _get(self, path: str, params: dict | None = None) -> dict:
25
- """Make a GET request to the API."""
26
- url = f"{self.base_url}{path}"
27
- if params:
28
- url += "?" + urllib.parse.urlencode(params)
29
-
30
- req = urllib.request.Request(url, headers={"Accept": "application/json"})
31
- with urllib.request.urlopen(req, timeout=30) as resp:
32
- return json.loads(resp.read().decode("utf-8"))
33
-
34
- def search(self, query: str, limit: int = 10, category: str | None = None) -> list[Factor]:
35
- """Search for emission factors by keyword.
36
-
37
- Args:
38
- query: Search query (e.g., "electricity", "diesel").
39
- limit: Maximum number of results (default: 10).
40
- category: Optional category filter.
41
-
42
- Returns:
43
- List of matching Factor objects.
44
- """
45
- params = {"q": query, "limit": str(limit)}
46
- if category:
47
- params["category"] = category
48
-
49
- data = self._get("/search", params)
50
- factors_data = data.get("factors", [])
51
- if not isinstance(factors_data, list):
52
- return []
53
- return [_dict_to_factor(f) for f in factors_data]
54
-
55
- def get_factor(self, factor_id: str) -> Optional[Factor]:
56
- """Get a factor by its ID.
57
-
58
- Args:
59
- factor_id: The unique factor identifier.
60
-
61
- Returns:
62
- Factor object or None if not found.
63
- """
64
- try:
65
- data = self._get(f"/factor/{factor_id}")
66
- if "error" in data:
67
- return None
68
- return _dict_to_factor(data)
69
- except Exception:
70
- return None
71
-
72
- def stats(self) -> dict:
73
- """Get database statistics."""
74
- return self._get("/stats")
75
-
76
-
77
- def _dict_to_factor(d: dict) -> Factor:
78
- """Convert API response dict to Factor object."""
79
- return Factor(
80
- id=d.get("id", ""),
81
- name=d.get("name", ""),
82
- category=d.get("category", ""),
83
- value=d.get("value", 0.0),
84
- unit=d.get("unit", ""),
85
- geography=d.get("geography", {}),
86
- applicability=d.get("applicability", ""),
87
- source=d.get("source", ""),
88
- source_year=d.get("source_year", 0),
89
- quality_technical=d.get("quality_technical"),
90
- quality_source=d.get("quality_source"),
91
- quality_geographical=d.get("quality_geographical"),
92
- quality_time=d.get("quality_time"),
93
- quality_fairness=d.get("quality_fairness"),
94
- )
@@ -1,14 +0,0 @@
1
- from dataclasses import dataclass
2
-
3
-
4
- @dataclass
5
- class LLMConfig:
6
- """Configuration for the LLM used in factor matching."""
7
-
8
- api_key: str = ""
9
- base_url: str = "https://api.deepseek.com"
10
- model: str = "deepseek-chat"
11
- max_tokens: int = 2048
12
- embedding_model: str = "shibing624/text2vec-base-chinese"
13
- license_key: str = ""
14
- usage_db_path: str = "usage.db"
@@ -1,220 +0,0 @@
1
- import json
2
- import logging
3
- from pathlib import Path
4
-
5
- from carbon_factor_matcher.models import Factor
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
-
10
- class FactorDatabase:
11
- """Loads and queries emission factors from multiple data sources.
12
-
13
- Supports loading from:
14
- - JSON-LD ILCD files (ELCD format) via data_dir
15
- - Custom adapters (ecoinvent Excel, etc.) via load_adapter()
16
- """
17
-
18
- def __init__(self, data_dir: str = "data/factors"):
19
- self._data_dir = Path(data_dir)
20
- self._factors: list[Factor] = []
21
- self._by_id: dict[str, Factor] = {}
22
- self._load()
23
-
24
- def load_adapter(self, adapter) -> None:
25
- """Load factors from a FactorAdapter.
26
-
27
- Args:
28
- adapter: A FactorAdapter instance (e.g., EcoinventExcelAdapter).
29
- """
30
- factors = adapter.load()
31
- for f in factors:
32
- if f.id in self._by_id:
33
- logger.debug(f"Duplicate factor ID, skipping: {f.id}")
34
- continue
35
- self._factors.append(f)
36
- self._by_id[f.id] = f
37
- logger.info(f"Adapter loaded {len(factors)} factors, total now: {len(self._factors)}")
38
-
39
- def _load(self) -> None:
40
- """Load all JSON-LD ILCD files from the data directory."""
41
- for json_file in self._data_dir.glob("**/*.json"):
42
- try:
43
- data = json.loads(json_file.read_text(encoding="utf-8"))
44
- if isinstance(data, list):
45
- for item in data:
46
- self._try_add_process(item)
47
- elif isinstance(data, dict):
48
- self._try_add_process(data)
49
- except (json.JSONDecodeError, KeyError):
50
- continue
51
-
52
- def _try_add_process(self, data: dict) -> None:
53
- """Try to parse a JSON-LD process into a Factor."""
54
- if data.get("@type") != "Process":
55
- return
56
-
57
- factor = self._parse_process(data)
58
- if factor:
59
- self._factors.append(factor)
60
- self._by_id[factor.id] = factor
61
-
62
- def _parse_process(self, data: dict) -> Factor | None:
63
- """Parse a JSON-LD ILCD Process into a Factor."""
64
- process_id = data.get("@id", "")
65
- name = data.get("name", "")
66
- if not process_id or not name:
67
- return None
68
-
69
- # Extract geography - handle both formats
70
- geography = {}
71
- # Format 1: location at top level (ELCD 3.x)
72
- if "location" in data and isinstance(data["location"], dict):
73
- loc_name = data["location"].get("name", "")
74
- if loc_name:
75
- geography["location"] = loc_name
76
- # Format 2: geography field
77
- geo_data = data.get("geography", {})
78
- if isinstance(geo_data, dict):
79
- location = geo_data.get("location", "")
80
- description = geo_data.get("description", "")
81
- if location:
82
- geography["location"] = location
83
- if description:
84
- geography["description"] = description
85
-
86
- # Extract time - handle both formats
87
- source_year = 0
88
- # Format 1: processDocumentation.validFrom (ELCD 3.x)
89
- doc = data.get("processDocumentation", {})
90
- if isinstance(doc, dict):
91
- valid_from = doc.get("validFrom", "")
92
- if valid_from and len(valid_from) >= 4:
93
- try:
94
- source_year = int(valid_from[:4])
95
- except ValueError:
96
- pass
97
- # Fallback to timeDescription
98
- if source_year == 0:
99
- time_desc = doc.get("timeDescription", "")
100
- if time_desc and len(time_desc) >= 4:
101
- try:
102
- source_year = int(time_desc[:4])
103
- except ValueError:
104
- pass
105
- # Format 2: time.startDate
106
- time_data = data.get("time", {})
107
- if isinstance(time_data, dict):
108
- start = time_data.get("startDate", "")
109
- if start and len(start) >= 4:
110
- try:
111
- source_year = int(start[:4])
112
- except ValueError:
113
- pass
114
-
115
- # Extract category
116
- category = data.get("category", "")
117
-
118
- # Extract the reference exchange (main output/emission)
119
- exchanges = data.get("exchanges", [])
120
- value, unit = self._extract_reference_value(exchanges)
121
-
122
- if value == 0:
123
- return None
124
-
125
- # Determine source from metadata
126
- source = self._extract_source(data)
127
-
128
- # Build applicability description
129
- applicability = self._build_applicability(data)
130
-
131
- return Factor(
132
- id=process_id,
133
- name=name,
134
- category=category,
135
- value=value,
136
- unit=unit,
137
- geography=geography,
138
- applicability=applicability,
139
- source=source,
140
- source_year=source_year,
141
- raw_data=data,
142
- )
143
-
144
- def _extract_reference_value(self, exchanges: list[dict]) -> tuple[float, str]:
145
- """Extract the reference exchange value and unit."""
146
- for ex in exchanges:
147
- # Skip input exchanges
148
- is_input = ex.get("isInput", ex.get("input", True))
149
- if is_input:
150
- continue
151
- # Try different amount field names
152
- result = ex.get("amount", ex.get("resultingAmount", 0))
153
- if result > 0:
154
- unit_ref = ex.get("unit", {})
155
- unit = unit_ref.get("name", "") if isinstance(unit_ref, dict) else ""
156
- return float(result), unit
157
- return 0.0, ""
158
-
159
- def _extract_source(self, data: dict) -> str:
160
- """Extract data source information."""
161
- # Check processDocumentation first (ELCD 3.x format)
162
- doc = data.get("processDocumentation", {})
163
- if isinstance(doc, dict):
164
- for key in ("dataGenerator", "publication", "dataSetOwner"):
165
- if key in doc:
166
- ref = doc[key]
167
- if isinstance(ref, dict) and "name" in ref:
168
- return ref["name"]
169
- if isinstance(ref, str):
170
- return ref
171
- # Fallback to top-level fields
172
- for key in ("dataGenerator", "publication", "commissioner"):
173
- if key in data:
174
- ref = data[key]
175
- if isinstance(ref, dict) and "name" in ref:
176
- return ref["name"]
177
- if isinstance(ref, str):
178
- return ref
179
- return "Unknown"
180
-
181
- def _build_applicability(self, data: dict) -> str:
182
- """Build an applicability description from process metadata."""
183
- parts = []
184
- # Check processDocumentation first (ELCD 3.x format)
185
- doc = data.get("processDocumentation", {})
186
- if isinstance(doc, dict):
187
- tech_desc = doc.get("technologyDescription", "")
188
- if tech_desc:
189
- parts.append(tech_desc)
190
- geo_desc = doc.get("geographyDescription", "")
191
- if geo_desc:
192
- parts.append(geo_desc)
193
- # Fallback to top-level fields
194
- if "technology" in data:
195
- tech = data["technology"]
196
- if isinstance(tech, dict) and "description" in tech:
197
- parts.append(tech["description"])
198
- if "geography" in data:
199
- geo = data["geography"]
200
- if isinstance(geo, dict) and "description" in geo:
201
- parts.append(geo["description"])
202
- return "; ".join(parts) if parts else data.get("name", "")
203
-
204
- @property
205
- def factors(self) -> list[Factor]:
206
- return list(self._factors)
207
-
208
- def search(self, query: str) -> list[Factor]:
209
- """Simple keyword search across factor name, category, applicability."""
210
- query_lower = query.lower()
211
- return [
212
- f for f in self._factors
213
- if query_lower in f.name.lower()
214
- or query_lower in f.category.lower()
215
- or query_lower in f.applicability.lower()
216
- ]
217
-
218
- def get_by_id(self, factor_id: str) -> Factor | None:
219
- """Get a factor by its ID."""
220
- return self._by_id.get(factor_id)