@nikeandocean/carbon-factor-matcher 0.2.0
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 +252 -0
- package/index.js +28 -0
- package/package.json +41 -0
- package/src/carbon_factor_matcher/__init__.py +3 -0
- package/src/carbon_factor_matcher/__main__.py +5 -0
- package/src/carbon_factor_matcher/adapters/__init__.py +6 -0
- package/src/carbon_factor_matcher/adapters/base.py +22 -0
- package/src/carbon_factor_matcher/adapters/ecoinvent.py +292 -0
- package/src/carbon_factor_matcher/api_client.py +94 -0
- package/src/carbon_factor_matcher/config.py +14 -0
- package/src/carbon_factor_matcher/factor_db.py +220 -0
- package/src/carbon_factor_matcher/license.py +80 -0
- package/src/carbon_factor_matcher/matcher.py +795 -0
- package/src/carbon_factor_matcher/models.py +71 -0
- package/src/carbon_factor_matcher/server.py +232 -0
- package/src/carbon_factor_matcher/usage_tracker.py +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Carbon Factor Matcher — MCP Server for Carbon Accounting
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server that connects LLMs with carbon footprint databases. It provides intelligent emission factor matching for carbon accounting, LCA (Life Cycle Assessment), and ESG reporting applications.
|
|
4
|
+
|
|
5
|
+
## What is this MCP?
|
|
6
|
+
|
|
7
|
+
Carbon Factor Matcher helps AI agents and LLM-based applications find the most appropriate emission factors from standardized environmental databases. It uses a two-stage hybrid search algorithm:
|
|
8
|
+
|
|
9
|
+
1. **Embedding-based rough filtering** — Semantic similarity to find candidate factors
|
|
10
|
+
2. **LLM-based fine ranking** — AI-powered selection with reasoning and confidence scores
|
|
11
|
+
|
|
12
|
+
### Supported Databases
|
|
13
|
+
|
|
14
|
+
- **ELCD** — European Reference Life Cycle Database (~600 factors, included in Free tier)
|
|
15
|
+
- **ecoinvent 3.10** — Swiss Centre for Life Cycle Inventories (~21,000 factors, Pro license required)
|
|
16
|
+
|
|
17
|
+
### Key Features
|
|
18
|
+
|
|
19
|
+
- 5-dimension data quality rating (technology, geography, source, time, factor type)
|
|
20
|
+
- Multi-language support (Chinese/English activity descriptions)
|
|
21
|
+
- MCP-compatible — works with Claude, Cursor, Windsurf, Cline, Continue, and any MCP client
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
### Claude Code
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
claude mcp add carbon-factor-matcher -- npx -y @nikeandocean/carbon-factor-matcher
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Claude Desktop
|
|
32
|
+
|
|
33
|
+
Add to `claude_desktop_config.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"carbon-factor-matcher": {
|
|
39
|
+
"command": "npx",
|
|
40
|
+
"args": ["-y", "@nikeandocean/carbon-factor-matcher"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Config file locations:
|
|
47
|
+
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
48
|
+
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
49
|
+
|
|
50
|
+
### Cursor
|
|
51
|
+
|
|
52
|
+
Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"carbon-factor-matcher": {
|
|
58
|
+
"command": "npx",
|
|
59
|
+
"args": ["-y", "@nikeandocean/carbon-factor-matcher"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or via Cursor UI: **Settings → MCP → Add new global MCP server**.
|
|
66
|
+
|
|
67
|
+
### Windsurf
|
|
68
|
+
|
|
69
|
+
Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"carbon-factor-matcher": {
|
|
75
|
+
"command": "npx",
|
|
76
|
+
"args": ["-y", "@nikeandocean/carbon-factor-matcher"]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or via Windsurf UI: **Settings → Cascade → MCP Servers → Add**.
|
|
83
|
+
|
|
84
|
+
### Cline (VS Code Extension)
|
|
85
|
+
|
|
86
|
+
Add to Cline MCP settings (click **MCP Servers** icon in Cline panel):
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"carbon-factor-matcher": {
|
|
92
|
+
"command": "npx",
|
|
93
|
+
"args": ["-y", "@nikeandocean/carbon-factor-matcher"]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Continue (VS Code / JetBrains)
|
|
100
|
+
|
|
101
|
+
Add to `~/.continue/config.yaml`:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
mcpServers:
|
|
105
|
+
- name: carbon-factor-matcher
|
|
106
|
+
command: npx
|
|
107
|
+
args:
|
|
108
|
+
- "-y"
|
|
109
|
+
- "@nikeandocean/carbon-factor-matcher"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Smithery.ai (One-Click Install)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npx -y smithery mcp add nikeandocean/carbon-factor-matcher
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or visit [smithery.ai/server/@nikeandocean/carbon-factor-matcher](https://smithery.ai/server/@nikeandocean/carbon-factor-matcher) for hosted endpoint.
|
|
119
|
+
|
|
120
|
+
## Pro License
|
|
121
|
+
|
|
122
|
+
The Free tier works immediately after installation (300 queries/day, ELCD database, keyword search). To unlock the full experience:
|
|
123
|
+
|
|
124
|
+
| Plan | Price | Features |
|
|
125
|
+
|------|-------|----------|
|
|
126
|
+
| **Free** | $0 | ELCD database (~600 factors), keyword search, 300 queries/day |
|
|
127
|
+
| **Pro** | $5 (one-time) | ELCD + ecoinvent (~21,000 factors), LLM-powered matching, unlimited queries, data quality rating |
|
|
128
|
+
|
|
129
|
+
### Purchase License Key
|
|
130
|
+
|
|
131
|
+
👉 **[Buy Pro License](https://nikeandocean.github.io/carbon-factor-matcher)**
|
|
132
|
+
|
|
133
|
+
After purchase, you'll receive a license key via email. Set it as an environment variable in your MCP config:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"mcpServers": {
|
|
138
|
+
"carbon-factor-matcher": {
|
|
139
|
+
"command": "npx",
|
|
140
|
+
"args": ["-y", "@nikeandocean/carbon-factor-matcher"],
|
|
141
|
+
"env": {
|
|
142
|
+
"CARBON_FACTOR_LICENSE_KEY": "PRO-xxxx-xxxx",
|
|
143
|
+
"LLM_API_KEY": "sk-xxxx",
|
|
144
|
+
"LLM_BASE_URL": "https://api.deepseek.com",
|
|
145
|
+
"LLM_MODEL": "deepseek-chat"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Configuration
|
|
153
|
+
|
|
154
|
+
| Variable | Description | Default |
|
|
155
|
+
|----------|-------------|---------|
|
|
156
|
+
| `CARBON_FACTOR_LICENSE_KEY` | Your license key (empty = Free tier) | — |
|
|
157
|
+
| `LLM_API_KEY` | LLM API key for Pro matching (DeepSeek/OpenAI) | — |
|
|
158
|
+
| `LLM_BASE_URL` | LLM endpoint URL | `https://api.deepseek.com` |
|
|
159
|
+
| `LLM_MODEL` | LLM model name | `deepseek-chat` |
|
|
160
|
+
| `CARBON_FACTOR_DATA_DIR` | Path to local factor database | `data/factors` |
|
|
161
|
+
|
|
162
|
+
## Available Tools
|
|
163
|
+
|
|
164
|
+
### `factor_match`
|
|
165
|
+
|
|
166
|
+
Match activity data to the best emission factor. Free tier uses keyword search; Pro tier uses hybrid embedding + LLM matching with quality assessment.
|
|
167
|
+
|
|
168
|
+
**Input:**
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"activity_data": "Factory in Shenzhen, 10kV industrial electricity, 2024, semiconductor fab",
|
|
172
|
+
"top_k": 10
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Output:**
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"selected_factor": {
|
|
180
|
+
"id": "elec-cn-south-10kv-2024",
|
|
181
|
+
"name": "Electricity, 10kV, South China Grid",
|
|
182
|
+
"value": 0.6101,
|
|
183
|
+
"unit": "kgCO2e/kWh"
|
|
184
|
+
},
|
|
185
|
+
"confidence": 0.92,
|
|
186
|
+
"reason": "Best match for industrial electricity in South China region",
|
|
187
|
+
"alternatives": [...]
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### `factor_search`
|
|
192
|
+
|
|
193
|
+
Search emission factors by keyword with optional filters.
|
|
194
|
+
|
|
195
|
+
**Input:**
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"query": "diesel",
|
|
199
|
+
"category": "fuel",
|
|
200
|
+
"limit": 10
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `factor_detail`
|
|
205
|
+
|
|
206
|
+
Get full metadata for a specific factor.
|
|
207
|
+
|
|
208
|
+
**Input:**
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"factor_id": "elec-cn-south-10kv-2024"
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## System Requirements
|
|
216
|
+
|
|
217
|
+
- Node.js 18+ (for `npx`)
|
|
218
|
+
- Python 3.11+ (auto-installed via pip)
|
|
219
|
+
- LLM API key (Pro tier only — DeepSeek recommended)
|
|
220
|
+
|
|
221
|
+
## Tech Stack
|
|
222
|
+
|
|
223
|
+
- **MCP SDK** — Model Context Protocol implementation
|
|
224
|
+
- **Sentence Transformers** — Semantic embedding (shibing624/text2vec-base-chinese)
|
|
225
|
+
- **DeepSeek/OpenAI** — LLM-based factor ranking
|
|
226
|
+
- **SQLite** — Usage tracking
|
|
227
|
+
- **Cloudflare Workers** — API and webhook processing
|
|
228
|
+
- **Paddle** — Payment processing
|
|
229
|
+
|
|
230
|
+
## Discovery
|
|
231
|
+
|
|
232
|
+
This MCP server is available on:
|
|
233
|
+
|
|
234
|
+
| Platform | Link | Status |
|
|
235
|
+
|----------|------|--------|
|
|
236
|
+
| **npm** | [@nikeandocean/carbon-factor-matcher](https://www.npmjs.com/package/@nikeandocean/carbon-factor-matcher) | Published |
|
|
237
|
+
| **Smithery.ai** | [smithery.ai/server/@nikeandocean/carbon-factor-matcher](https://smithery.ai/server/@nikeandocean/carbon-factor-matcher) | Listed |
|
|
238
|
+
| **mcp.so** | [mcp.so](https://mcp.so) | Submit via site |
|
|
239
|
+
| **Glama.ai** | [glama.ai/mcp/servers](https://glama.ai/mcp/servers) | Auto-indexed from npm |
|
|
240
|
+
|
|
241
|
+
## Support
|
|
242
|
+
|
|
243
|
+
- **Email:** tao.yan@zju.edu.cn
|
|
244
|
+
- **Issues:** https://github.com/tra121vel/carbon-factor-matcher/issues
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
This is proprietary software. See [LICENSE](LICENSE) for details.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
© 2024 Carbon Factor Matcher. All rights reserved.
|
package/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carbon Factor Matcher - MCP Server
|
|
5
|
+
*
|
|
6
|
+
* npm wrapper that launches the Python MCP server.
|
|
7
|
+
* Requires: Python 3.11+, pip install carbon-factor-matcher
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execSync, spawn } = require('child_process');
|
|
11
|
+
|
|
12
|
+
// Check if Python is available
|
|
13
|
+
try {
|
|
14
|
+
execSync('python --version', { stdio: 'ignore' });
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error('Error: Python 3.11+ is required to run carbon-factor-matcher');
|
|
17
|
+
console.error('Install from https://www.python.org/downloads/');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Launch the MCP server
|
|
22
|
+
const child = spawn('python', ['-m', 'carbon_factor_matcher'], {
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
child.on('exit', (code) => {
|
|
27
|
+
process.exit(code || 0);
|
|
28
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nikeandocean/carbon-factor-matcher",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for intelligent emission factor matching — connects LLMs with carbon emission factor databases (ELCD, ecoinvent)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"carbon-factor-matcher": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"src/carbon_factor_matcher/**/*.py",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node index.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"mcp-server",
|
|
21
|
+
"carbon",
|
|
22
|
+
"emission-factor",
|
|
23
|
+
"carbon-accounting",
|
|
24
|
+
"lca",
|
|
25
|
+
"life-cycle-assessment",
|
|
26
|
+
"ecoinvent",
|
|
27
|
+
"elcd",
|
|
28
|
+
"sustainability",
|
|
29
|
+
"climate",
|
|
30
|
+
"esg",
|
|
31
|
+
"ghg",
|
|
32
|
+
"carbon-footprint",
|
|
33
|
+
"co2"
|
|
34
|
+
],
|
|
35
|
+
"author": "tra121vel",
|
|
36
|
+
"license": "Proprietary",
|
|
37
|
+
"dependencies": {},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Base class for factor database adapters."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from carbon_factor_matcher.models import Factor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FactorAdapter(ABC):
|
|
9
|
+
"""Abstract base class for loading emission factors from different data sources.
|
|
10
|
+
|
|
11
|
+
Each adapter knows how to parse a specific format (ELCD JSON-LD,
|
|
12
|
+
ecoinvent Excel, EcoSpold2 XML, etc.) and return a list of Factor objects.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def load(self) -> list[Factor]:
|
|
17
|
+
"""Load all factors from the data source.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
List of Factor objects ready for matching.
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
@@ -0,0 +1,292 @@
|
|
|
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
|