@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.
- package/README.md +236 -252
- package/index.js +20 -1
- package/package.json +1 -2
- package/src/carbon_factor_matcher/__init__.py +0 -3
- package/src/carbon_factor_matcher/__main__.py +0 -5
- package/src/carbon_factor_matcher/adapters/__init__.py +0 -6
- package/src/carbon_factor_matcher/adapters/base.py +0 -22
- package/src/carbon_factor_matcher/adapters/ecoinvent.py +0 -292
- package/src/carbon_factor_matcher/api_client.py +0 -94
- package/src/carbon_factor_matcher/config.py +0 -14
- package/src/carbon_factor_matcher/factor_db.py +0 -220
- package/src/carbon_factor_matcher/license.py +0 -80
- package/src/carbon_factor_matcher/matcher.py +0 -795
- package/src/carbon_factor_matcher/models.py +0 -71
- package/src/carbon_factor_matcher/server.py +0 -232
- package/src/carbon_factor_matcher/usage_tracker.py +0 -68
|
@@ -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)
|