ecosemantic-mcp 0.1.8__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.
- ecosemantic_mcp-0.1.8/.gitignore +24 -0
- ecosemantic_mcp-0.1.8/LICENSE +73 -0
- ecosemantic_mcp-0.1.8/NOTICE +13 -0
- ecosemantic_mcp-0.1.8/PKG-INFO +55 -0
- ecosemantic_mcp-0.1.8/README.md +23 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/__init__.py +0 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/calculation.py +440 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/calls.py +50 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/context.py +116 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/counting.py +39 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/discovery.py +219 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/effects.py +36 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/envelopes.py +309 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/graph.py +160 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/guides/building-a-model.md +64 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/guides/interpreting-a-result.md +78 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/guides/sensitivity-study.md +66 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/guides.py +51 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/installs.py +179 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/linking.py +53 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/modelling.py +300 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/panels/contribution.html +66 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/panels/impact.html +76 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/panels/inventory.html +65 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/panels.py +59 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/parameters.py +168 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/py.typed +0 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/recording.py +181 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/requests.py +116 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/runs.py +122 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/server.py +230 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/systems.py +316 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/tables.py +106 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/treatments.py +54 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/view.py +33 -0
- ecosemantic_mcp-0.1.8/ecosemantic/mcp/watcher.py +117 -0
- ecosemantic_mcp-0.1.8/pyproject.toml +46 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
node_modules/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
.coverage
|
|
13
|
+
coverage.json
|
|
14
|
+
htmlcov/
|
|
15
|
+
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.example
|
|
19
|
+
|
|
20
|
+
.DS_Store
|
|
21
|
+
graphify-out
|
|
22
|
+
knowledge/
|
|
23
|
+
.okf-cache.json
|
|
24
|
+
**/ecosemantic/view/static/
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# PolyForm Noncommercial License 1.0.0
|
|
2
|
+
|
|
3
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
12
|
+
|
|
13
|
+
## Distribution License
|
|
14
|
+
|
|
15
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
16
|
+
|
|
17
|
+
## Notices
|
|
18
|
+
|
|
19
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
20
|
+
|
|
21
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
22
|
+
|
|
23
|
+
## Changes and New Works License
|
|
24
|
+
|
|
25
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
26
|
+
|
|
27
|
+
## Patent License
|
|
28
|
+
|
|
29
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
30
|
+
|
|
31
|
+
## Noncommercial Purposes
|
|
32
|
+
|
|
33
|
+
Any noncommercial purpose is a permitted purpose.
|
|
34
|
+
|
|
35
|
+
## Personal Uses
|
|
36
|
+
|
|
37
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
38
|
+
|
|
39
|
+
## Noncommercial Organizations
|
|
40
|
+
|
|
41
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
42
|
+
|
|
43
|
+
## Fair Use
|
|
44
|
+
|
|
45
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
46
|
+
|
|
47
|
+
## No Other Rights
|
|
48
|
+
|
|
49
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
50
|
+
|
|
51
|
+
## Patent Defense
|
|
52
|
+
|
|
53
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
54
|
+
|
|
55
|
+
## Violations
|
|
56
|
+
|
|
57
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
58
|
+
|
|
59
|
+
## No Liability
|
|
60
|
+
|
|
61
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
62
|
+
|
|
63
|
+
## Definitions
|
|
64
|
+
|
|
65
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
66
|
+
|
|
67
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
68
|
+
|
|
69
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
70
|
+
|
|
71
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
72
|
+
|
|
73
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
EcoSemantic
|
|
2
|
+
Required Notice: Copyright 2026 Carlos Gaete-Morales (https://github.com/cdgaete/ecosemantic)
|
|
3
|
+
|
|
4
|
+
EcoSemantic is licensed under the PolyForm Noncommercial License 1.0.0, in LICENSE
|
|
5
|
+
and at https://polyformproject.org/licenses/noncommercial/1.0.0. Any noncommercial
|
|
6
|
+
purpose is permitted. Commercial use needs a separate licence from Carlos
|
|
7
|
+
Gaete-Morales: cdgaete@gmail.com.
|
|
8
|
+
|
|
9
|
+
The page that ecosemantic-view carries bundles third-party software under its
|
|
10
|
+
own licences; their notices are in ecosemantic/view/static/THIRD_PARTY_NOTICES.txt
|
|
11
|
+
inside that distribution. Components of the page adapt shadcn/ui, Copyright (c)
|
|
12
|
+
2023 shadcn, under the MIT licence, as page/PROVENANCE.md in the source
|
|
13
|
+
repository records.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ecosemantic-mcp
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: The EcoSemantic MCP tools and the server that registers them.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cdgaete/ecosemantic
|
|
6
|
+
Project-URL: Repository, https://github.com/cdgaete/ecosemantic
|
|
7
|
+
Project-URL: Issues, https://github.com/cdgaete/ecosemantic/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/cdgaete/ecosemantic/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Carlos Gaete-Morales <cdgaete@gmail.com>
|
|
10
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
13
|
+
Keywords: LCA,MCP,Model Context Protocol,agents,life cycle assessment
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: ecosemantic-core==0.1.8
|
|
25
|
+
Requires-Dist: ecosemantic-engine==0.1.8
|
|
26
|
+
Requires-Dist: fastmcp-slim<5,>=4
|
|
27
|
+
Requires-Dist: fastmcp<5,>=4
|
|
28
|
+
Requires-Dist: mcp<3,>=2.2.0
|
|
29
|
+
Requires-Dist: opentelemetry-api<2,>=1.44
|
|
30
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ecosemantic-mcp
|
|
34
|
+
|
|
35
|
+
The MCP tools of EcoSemantic, each a plain function over the ports bound in `ServerContext`, and the server that registers them for a chatbot: search the installed databases, model a product system, and calculate its impacts, contributions, inventory and Monte Carlo runs.
|
|
36
|
+
|
|
37
|
+
## Names it publishes
|
|
38
|
+
|
|
39
|
+
- `ecosemantic.mcp.server` — the stdio server: what it is called, the tools it registers and the order it registers them in.
|
|
40
|
+
- The tool modules, each a set of module-level functions taking the `ServerContext`: `discovery` (what is installed), `modelling` (processes and exchanges), `linking` (what the stored systems' links hold), `parameters`, `calculation` (impacts, contributions, inventory), `tables`, `graph` (the drawing), `systems` (the stored systems and their log), `requests` (the request tools, and the answer every write tool returns), `installs`, `runs`, `treatments`, `envelopes` (the answer every tool returns) and `calls` (the tool call in progress, and what it hands the recorder).
|
|
41
|
+
- `ecosemantic.mcp.guides` — the three task guides served as resources: building a model, interpreting a result and a sensitivity study.
|
|
42
|
+
- `ecosemantic.mcp.panels` — the in-chat panels a host renders beside a tool's result.
|
|
43
|
+
- `ecosemantic.mcp.watcher` — the server's view of a run or an install in another process: poll the record, publish what changed, and settle one whose process is gone.
|
|
44
|
+
|
|
45
|
+
## What it depends on
|
|
46
|
+
|
|
47
|
+
`ecosemantic-engine` and `ecosemantic-core` at the same version, and fastmcp, fastmcp-slim, mcp and pydantic. It imports nothing from `ecosemantic.view`: the tools a page reads are the tools a chatbot calls.
|
|
48
|
+
|
|
49
|
+
## Installing it
|
|
50
|
+
|
|
51
|
+
`pip install ecosemantic` installs this distribution with the other five and the `ecosemantic` command that serves it. `pip install ecosemantic-mcp` installs it alone, for a host that registers the tools over its own transport.
|
|
52
|
+
|
|
53
|
+
## Licence
|
|
54
|
+
|
|
55
|
+
PolyForm Noncommercial License 1.0.0, copyright Carlos Gaete-Morales: `LICENSE` and `NOTICE` in this distribution. Any noncommercial purpose is permitted, by individuals and by charities, schools, public research and government bodies; commercial use needs a separate licence from him (cdgaete@gmail.com). EcoSemantic is his trademark, and the licence grants no right to use that name (`TRADEMARK.md` in the repository). The code is published from https://github.com/cdgaete/ecosemantic.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ecosemantic-mcp
|
|
2
|
+
|
|
3
|
+
The MCP tools of EcoSemantic, each a plain function over the ports bound in `ServerContext`, and the server that registers them for a chatbot: search the installed databases, model a product system, and calculate its impacts, contributions, inventory and Monte Carlo runs.
|
|
4
|
+
|
|
5
|
+
## Names it publishes
|
|
6
|
+
|
|
7
|
+
- `ecosemantic.mcp.server` — the stdio server: what it is called, the tools it registers and the order it registers them in.
|
|
8
|
+
- The tool modules, each a set of module-level functions taking the `ServerContext`: `discovery` (what is installed), `modelling` (processes and exchanges), `linking` (what the stored systems' links hold), `parameters`, `calculation` (impacts, contributions, inventory), `tables`, `graph` (the drawing), `systems` (the stored systems and their log), `requests` (the request tools, and the answer every write tool returns), `installs`, `runs`, `treatments`, `envelopes` (the answer every tool returns) and `calls` (the tool call in progress, and what it hands the recorder).
|
|
9
|
+
- `ecosemantic.mcp.guides` — the three task guides served as resources: building a model, interpreting a result and a sensitivity study.
|
|
10
|
+
- `ecosemantic.mcp.panels` — the in-chat panels a host renders beside a tool's result.
|
|
11
|
+
- `ecosemantic.mcp.watcher` — the server's view of a run or an install in another process: poll the record, publish what changed, and settle one whose process is gone.
|
|
12
|
+
|
|
13
|
+
## What it depends on
|
|
14
|
+
|
|
15
|
+
`ecosemantic-engine` and `ecosemantic-core` at the same version, and fastmcp, fastmcp-slim, mcp and pydantic. It imports nothing from `ecosemantic.view`: the tools a page reads are the tools a chatbot calls.
|
|
16
|
+
|
|
17
|
+
## Installing it
|
|
18
|
+
|
|
19
|
+
`pip install ecosemantic` installs this distribution with the other five and the `ecosemantic` command that serves it. `pip install ecosemantic-mcp` installs it alone, for a host that registers the tools over its own transport.
|
|
20
|
+
|
|
21
|
+
## Licence
|
|
22
|
+
|
|
23
|
+
PolyForm Noncommercial License 1.0.0, copyright Carlos Gaete-Morales: `LICENSE` and `NOTICE` in this distribution. Any noncommercial purpose is permitted, by individuals and by charities, schools, public research and government bodies; commercial use needs a separate licence from him (cdgaete@gmail.com). EcoSemantic is his trademark, and the licence grants no right to use that name (`TRADEMARK.md` in the repository). The code is published from https://github.com/cdgaete/ecosemantic.
|
|
File without changes
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""The calculation tools: an impact, its contributions, and its inventory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from ecosemantic.compute.links import held_pins, linked_credit_loops, linked_documents, pinned
|
|
12
|
+
from ecosemantic.compute.montecarlo import paired_delta, paired_samples
|
|
13
|
+
from ecosemantic.compute.overrides import apply_variant, stale
|
|
14
|
+
from ecosemantic.compute.parameters import materialize
|
|
15
|
+
from ecosemantic.compute.results import ContributionResult, ImpactResult, PinBehind, VariantScores
|
|
16
|
+
from ecosemantic.errors import Invalid, NotFound, StaleScenario
|
|
17
|
+
from ecosemantic.mcp.context import ServerContext
|
|
18
|
+
from ecosemantic.mcp.effects import COMPUTE, READ
|
|
19
|
+
from ecosemantic.mcp.envelopes import (
|
|
20
|
+
Answer,
|
|
21
|
+
Listing,
|
|
22
|
+
applied_filters,
|
|
23
|
+
as_uuid,
|
|
24
|
+
bound,
|
|
25
|
+
boundary,
|
|
26
|
+
empty_search_hint,
|
|
27
|
+
listing,
|
|
28
|
+
suggestion_hint,
|
|
29
|
+
)
|
|
30
|
+
from ecosemantic.mcp.linking import moved_by
|
|
31
|
+
from ecosemantic.mcp.panels import panel
|
|
32
|
+
from ecosemantic.mcp.treatments import backwards_in, named_backwards, treatments_in
|
|
33
|
+
from ecosemantic.model.canonical import base_pin
|
|
34
|
+
from ecosemantic.model.entities import DemandItem, Method, ProductSystem
|
|
35
|
+
from ecosemantic.model.refs import BackgroundRef, Database, ForegroundRef
|
|
36
|
+
from ecosemantic.model.validation import credit_loops, reference_of
|
|
37
|
+
from ecosemantic.ports.calculation import CUTOFF, MAX_LEVEL, MAX_NODES, TOP_N, CalculationPort
|
|
38
|
+
from ecosemantic.ports.inventory import InventoryPort
|
|
39
|
+
from ecosemantic.ports.types import (
|
|
40
|
+
Decomposition,
|
|
41
|
+
InventoryRow,
|
|
42
|
+
LinkedLoop,
|
|
43
|
+
MonteCarloConfig,
|
|
44
|
+
Page,
|
|
45
|
+
System,
|
|
46
|
+
offset_cursor,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
MAX_ITERATIONS = 200
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ImpactMonteCarlo(MonteCarloConfig):
|
|
53
|
+
"""What `calculate_impact` samples: `MonteCarloConfig`, with every count capped at the draws
|
|
54
|
+
one call takes. `start_monte_carlo` takes the config whole."""
|
|
55
|
+
|
|
56
|
+
n_min: int = Field(
|
|
57
|
+
default=30,
|
|
58
|
+
ge=1,
|
|
59
|
+
le=MAX_ITERATIONS,
|
|
60
|
+
description=(
|
|
61
|
+
"The fewest draws an adaptive run takes. It bounds the run with `n_max` and "
|
|
62
|
+
"applies only when `n` is unset."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
n_max: int = Field(
|
|
66
|
+
default=200,
|
|
67
|
+
ge=1,
|
|
68
|
+
le=MAX_ITERATIONS,
|
|
69
|
+
description=(
|
|
70
|
+
"The most draws an adaptive run takes, between `n_min` and `n_max`. It applies "
|
|
71
|
+
"only when `n` is unset."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
n: int | None = Field(
|
|
75
|
+
default=None,
|
|
76
|
+
ge=1,
|
|
77
|
+
le=MAX_ITERATIONS,
|
|
78
|
+
description=(
|
|
79
|
+
"The count a run takes, fixed. Unset leaves the run adaptive: it stops between "
|
|
80
|
+
"`n_min` and `n_max` once `rel_se_target` is met."
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class InventoryListing(Listing[InventoryRow]):
|
|
86
|
+
"""One page of inventory rows over the database build `database` names, with the processes
|
|
87
|
+
of each loop holding a credit, whose delivery no check decides, each exchange sending a
|
|
88
|
+
positive amount to a treatment, which runs it backwards, and the amounts set aside as
|
|
89
|
+
float64 residue: those at or below `floor`, which `dropped` counts."""
|
|
90
|
+
|
|
91
|
+
database: Database
|
|
92
|
+
credit_loops: list[list[str]]
|
|
93
|
+
floor: float
|
|
94
|
+
dropped: int
|
|
95
|
+
linked_credit_loops: list[LinkedLoop] = Field(default_factory=list)
|
|
96
|
+
treatments_run_backwards: list[str] = Field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def inventory_page(
|
|
100
|
+
rows: list[InventoryRow], compartment: str | None, query: str | None, limit: int, offset: int
|
|
101
|
+
) -> Page[InventoryRow]:
|
|
102
|
+
"""The rows that pass the filters, in compartment then name order, from the offset."""
|
|
103
|
+
terms = [t.lower() for t in (query or "").split()]
|
|
104
|
+
kept = sorted(
|
|
105
|
+
(
|
|
106
|
+
r
|
|
107
|
+
for r in rows
|
|
108
|
+
if (compartment is None or r.compartment == compartment)
|
|
109
|
+
and all(t in r.name.lower() for t in terms)
|
|
110
|
+
),
|
|
111
|
+
key=lambda r: (r.compartment, r.name, r.flow.id),
|
|
112
|
+
)
|
|
113
|
+
return Page.at_offset(kept[offset : offset + limit], len(kept) > offset + limit, offset, limit)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def demand_of(system: ProductSystem, demand: list[DemandItem] | None) -> list[DemandItem]:
|
|
117
|
+
"""The demand as the port takes it; without one, a unit of the reference process."""
|
|
118
|
+
if not demand:
|
|
119
|
+
return [DemandItem(ref=ForegroundRef(code=reference_of(system, "demand")), amount=1.0)]
|
|
120
|
+
return demand
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def methods_for(
|
|
124
|
+
inventory: InventoryPort, database: str, method_id: str | None, impact_method: str | None
|
|
125
|
+
) -> tuple[list[Method], str | None]:
|
|
126
|
+
"""The methods to characterise with, and a hint when a specification was used for an id."""
|
|
127
|
+
if method_id is None:
|
|
128
|
+
if impact_method is None:
|
|
129
|
+
raise Invalid("name exactly one of method_id or impact_method")
|
|
130
|
+
found = await inventory.list_methods(database, impact_method)
|
|
131
|
+
if not found:
|
|
132
|
+
raise NotFound(f"no impact method {impact_method!r} in {database!r}")
|
|
133
|
+
return found, None
|
|
134
|
+
if impact_method is not None:
|
|
135
|
+
raise Invalid("name exactly one of method_id or impact_method")
|
|
136
|
+
method = await inventory.resolve_method(database, method_id)
|
|
137
|
+
hint = None if method_id == method.id else suggestion_hint(method_id, method.id)
|
|
138
|
+
return [method], hint
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def one_method(
|
|
142
|
+
inventory: InventoryPort, database: str, method_id: str | None, impact_method: str | None
|
|
143
|
+
) -> tuple[Method, str | None]:
|
|
144
|
+
"""Exactly one method, for the tools that decompose a single total."""
|
|
145
|
+
methods, hint = await methods_for(inventory, database, method_id, impact_method)
|
|
146
|
+
if len(methods) != 1:
|
|
147
|
+
specs = [m.spec for m in methods]
|
|
148
|
+
raise Invalid(f"this tool takes one method; {impact_method!r} names {len(specs)}")
|
|
149
|
+
return methods[0], hint
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def impact_of(
|
|
153
|
+
calculation: CalculationPort,
|
|
154
|
+
base: ProductSystem,
|
|
155
|
+
methods: list[Method],
|
|
156
|
+
demand: list[DemandItem] | None = None,
|
|
157
|
+
variants: Sequence[str] = (),
|
|
158
|
+
monte_carlo: MonteCarloConfig | None = None,
|
|
159
|
+
linked: dict[uuid.UUID, ProductSystem] | None = None,
|
|
160
|
+
treatments: frozenset[BackgroundRef] | None = None,
|
|
161
|
+
) -> ImpactResult:
|
|
162
|
+
"""The scores of a materialised product system and of each named variant against them, with
|
|
163
|
+
the uncertainty of both when a Monte Carlo block is given. `linked` holds every system the
|
|
164
|
+
base reaches through its links, each at its pin and materialised; given the background's
|
|
165
|
+
`treatments`, the base and each variant name every exchange sending a positive amount to
|
|
166
|
+
one."""
|
|
167
|
+
linked = linked or {}
|
|
168
|
+
inside = linked_credit_loops(linked.values())
|
|
169
|
+
items = demand_of(base, demand)
|
|
170
|
+
system = System.of(base, linked)
|
|
171
|
+
scores = await calculation.scores(system, items, methods)
|
|
172
|
+
uncertainty = (
|
|
173
|
+
await calculation.monte_carlo(system, items, methods, monte_carlo)
|
|
174
|
+
if monte_carlo is not None
|
|
175
|
+
else None
|
|
176
|
+
)
|
|
177
|
+
result = ImpactResult(
|
|
178
|
+
product_system=base.name,
|
|
179
|
+
database=base.database,
|
|
180
|
+
scores=scores,
|
|
181
|
+
credit_loops=credit_loops(base),
|
|
182
|
+
linked_credit_loops=inside,
|
|
183
|
+
uncertainty=uncertainty,
|
|
184
|
+
treatments_run_backwards=named_backwards(base, linked, treatments),
|
|
185
|
+
)
|
|
186
|
+
for name in variants:
|
|
187
|
+
varied = apply_variant(base, name)
|
|
188
|
+
varied_system = System.of(varied, linked)
|
|
189
|
+
varied_items = demand_of(varied, demand)
|
|
190
|
+
varied_scores = await calculation.scores(varied_system, varied_items, methods)
|
|
191
|
+
varied_mc = None
|
|
192
|
+
paired = None
|
|
193
|
+
if monte_carlo is not None and uncertainty is not None:
|
|
194
|
+
varied_mc = await calculation.monte_carlo(
|
|
195
|
+
varied_system, varied_items, methods, monte_carlo
|
|
196
|
+
)
|
|
197
|
+
paired = [
|
|
198
|
+
paired_delta(*paired_samples(uncertainty, varied_mc, c))
|
|
199
|
+
for c in range(len(uncertainty.categories))
|
|
200
|
+
]
|
|
201
|
+
deltas = [v.value - b.value for b, v in zip(scores, varied_scores, strict=True)]
|
|
202
|
+
result.variants.append(
|
|
203
|
+
VariantScores(
|
|
204
|
+
name=name,
|
|
205
|
+
scores=varied_scores,
|
|
206
|
+
deltas=deltas,
|
|
207
|
+
credit_loops=credit_loops(varied),
|
|
208
|
+
linked_credit_loops=inside,
|
|
209
|
+
treatments_run_backwards=named_backwards(varied, linked, treatments),
|
|
210
|
+
uncertainty=varied_mc,
|
|
211
|
+
paired_deltas=paired,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def result_link(
|
|
218
|
+
context: ServerContext,
|
|
219
|
+
system: ProductSystem,
|
|
220
|
+
page: str,
|
|
221
|
+
method_id: str | None = None,
|
|
222
|
+
impact_method: str | None = None,
|
|
223
|
+
variants: Sequence[str] = (),
|
|
224
|
+
demand: list[DemandItem] | None = None,
|
|
225
|
+
) -> str | None:
|
|
226
|
+
"""The result page that reproduces a calculation, in the query parameters the page's
|
|
227
|
+
results editor reads: the method, the variants, and the amount of the reference process
|
|
228
|
+
when the demand is that process alone."""
|
|
229
|
+
params = [("method_id", method_id), ("impact_method", impact_method)]
|
|
230
|
+
params.append(("variants", ",".join(variants) or None))
|
|
231
|
+
if (
|
|
232
|
+
demand is not None
|
|
233
|
+
and len(demand) == 1
|
|
234
|
+
and isinstance(demand[0].ref, ForegroundRef)
|
|
235
|
+
and demand[0].ref.code == system.reference_process
|
|
236
|
+
):
|
|
237
|
+
params.append(("amount", str(demand[0].amount)))
|
|
238
|
+
given = [(k, v) for k, v in params if v is not None]
|
|
239
|
+
return context.page_link(system.id, f"/results/{page}", given)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def refuse_stale(
|
|
243
|
+
context: ServerContext, stored: ProductSystem, variants: Sequence[str]
|
|
244
|
+
) -> None:
|
|
245
|
+
"""StaleScenario for the first named scenario pinned to a base the system has left, naming
|
|
246
|
+
the entry of its log that moved the base."""
|
|
247
|
+
for scenario in stored.scenarios:
|
|
248
|
+
if scenario.name in variants and stale(stored, scenario):
|
|
249
|
+
entry = await moved_by(context.storage, stored.id, scenario.base_hash)
|
|
250
|
+
raise StaleScenario(
|
|
251
|
+
scenario.name,
|
|
252
|
+
scenario.base_hash,
|
|
253
|
+
base_pin(stored),
|
|
254
|
+
None if entry is None else f"entry {entry.seq} ({entry.message})",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def calculate_impact(
|
|
259
|
+
context: ServerContext,
|
|
260
|
+
product_system_id: str,
|
|
261
|
+
method_id: str | None = None,
|
|
262
|
+
impact_method: str | None = None,
|
|
263
|
+
demand: list[DemandItem] | None = None,
|
|
264
|
+
variants: list[str] | None = None,
|
|
265
|
+
monte_carlo: ImpactMonteCarlo | None = None,
|
|
266
|
+
) -> Answer[ImpactResult]:
|
|
267
|
+
stored = await context.storage.get_product_system(as_uuid(product_system_id))
|
|
268
|
+
await refuse_stale(context, stored, variants or ())
|
|
269
|
+
linked = await linked_documents(stored, context.storage.get_version)
|
|
270
|
+
base = materialize(stored)
|
|
271
|
+
methods, hint = await methods_for(
|
|
272
|
+
context.inventory, stored.database.name, method_id, impact_method
|
|
273
|
+
)
|
|
274
|
+
result = await impact_of(
|
|
275
|
+
context.calculation,
|
|
276
|
+
base,
|
|
277
|
+
methods,
|
|
278
|
+
demand=demand,
|
|
279
|
+
variants=variants or (),
|
|
280
|
+
monte_carlo=monte_carlo,
|
|
281
|
+
linked=linked,
|
|
282
|
+
treatments=await treatments_in(context.inventory, [base, *linked.values()]),
|
|
283
|
+
)
|
|
284
|
+
result.links_behind = [
|
|
285
|
+
PinBehind(product_system_id=p.ref.product_system_id, name=p.name, at_hash=pinned(p.ref))
|
|
286
|
+
for p in await held_pins(stored, context.storage)
|
|
287
|
+
if p.name is not None and p.current != p.at
|
|
288
|
+
]
|
|
289
|
+
link = result_link(context, stored, "impact", method_id, impact_method, variants or (), demand)
|
|
290
|
+
return Answer[ImpactResult](value=result, hint=hint, view_url=link)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def contribution_analysis(
|
|
294
|
+
context: ServerContext,
|
|
295
|
+
product_system_id: str,
|
|
296
|
+
by: Decomposition = "process",
|
|
297
|
+
method_id: str | None = None,
|
|
298
|
+
impact_method: str | None = None,
|
|
299
|
+
demand: list[DemandItem] | None = None,
|
|
300
|
+
top_n: int = TOP_N,
|
|
301
|
+
max_level: int = MAX_LEVEL,
|
|
302
|
+
max_nodes: int = MAX_NODES,
|
|
303
|
+
cutoff: float = CUTOFF,
|
|
304
|
+
) -> Answer[ContributionResult]:
|
|
305
|
+
stored = await context.storage.get_product_system(as_uuid(product_system_id))
|
|
306
|
+
linked = await linked_documents(stored, context.storage.get_version)
|
|
307
|
+
base = materialize(stored)
|
|
308
|
+
method, hint = await one_method(
|
|
309
|
+
context.inventory, stored.database.name, method_id, impact_method
|
|
310
|
+
)
|
|
311
|
+
items = demand_of(base, demand)
|
|
312
|
+
system = System.of(base, linked)
|
|
313
|
+
named = credit_loops(base)
|
|
314
|
+
inside = linked_credit_loops(linked.values())
|
|
315
|
+
backwards = await backwards_in(context.inventory, base, linked)
|
|
316
|
+
if by == "process":
|
|
317
|
+
found = await context.calculation.hotspots(system, items, method, top_n)
|
|
318
|
+
value = ContributionResult(
|
|
319
|
+
database=base.database,
|
|
320
|
+
by=by,
|
|
321
|
+
total=found.total,
|
|
322
|
+
processes=found.items,
|
|
323
|
+
credit_loops=named,
|
|
324
|
+
linked_credit_loops=inside,
|
|
325
|
+
treatments_run_backwards=backwards,
|
|
326
|
+
)
|
|
327
|
+
elif by == "flow":
|
|
328
|
+
flows = await context.calculation.flow_hotspots(system, items, method, top_n)
|
|
329
|
+
value = ContributionResult(
|
|
330
|
+
database=base.database,
|
|
331
|
+
by=by,
|
|
332
|
+
total=flows.total,
|
|
333
|
+
flows=flows.items,
|
|
334
|
+
credit_loops=named,
|
|
335
|
+
linked_credit_loops=inside,
|
|
336
|
+
treatments_run_backwards=backwards,
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
tree = await context.calculation.contribution_tree(
|
|
340
|
+
system, items, method, max_level, max_nodes, cutoff
|
|
341
|
+
)
|
|
342
|
+
value = ContributionResult(
|
|
343
|
+
database=base.database,
|
|
344
|
+
by=by,
|
|
345
|
+
total=tree.total,
|
|
346
|
+
root=tree.root,
|
|
347
|
+
truncated=tree.truncated,
|
|
348
|
+
credit_loops=named,
|
|
349
|
+
linked_credit_loops=inside,
|
|
350
|
+
treatments_run_backwards=backwards,
|
|
351
|
+
)
|
|
352
|
+
link = result_link(context, stored, "tree", method_id, impact_method, demand=demand)
|
|
353
|
+
return Answer[ContributionResult](value=value, hint=hint, view_url=link)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def calculate_inventory(
|
|
357
|
+
context: ServerContext,
|
|
358
|
+
product_system_id: str,
|
|
359
|
+
demand: list[DemandItem] | None = None,
|
|
360
|
+
compartment: str | None = None,
|
|
361
|
+
query: str | None = None,
|
|
362
|
+
limit: int = 100,
|
|
363
|
+
cursor: str | None = None,
|
|
364
|
+
) -> InventoryListing:
|
|
365
|
+
stored = await context.storage.get_product_system(as_uuid(product_system_id))
|
|
366
|
+
linked = await linked_documents(stored, context.storage.get_version)
|
|
367
|
+
base = materialize(stored)
|
|
368
|
+
items = demand_of(base, demand)
|
|
369
|
+
system = System.of(base, linked)
|
|
370
|
+
found = await context.calculation.inventory(system, items)
|
|
371
|
+
page = inventory_page(found.rows, compartment, query, limit, offset_cursor(cursor))
|
|
372
|
+
filters = {"compartment": compartment}
|
|
373
|
+
words = bool(applied_filters(filters)) and bool(
|
|
374
|
+
inventory_page(found.rows, None, query, 1, 0).items
|
|
375
|
+
)
|
|
376
|
+
out = listing(page, empty_search_hint(filters, words)) if not page.items else listing(page)
|
|
377
|
+
return InventoryListing(
|
|
378
|
+
database=base.database,
|
|
379
|
+
items=out.items,
|
|
380
|
+
has_more=out.has_more,
|
|
381
|
+
next_cursor=out.next_cursor,
|
|
382
|
+
hint=out.hint,
|
|
383
|
+
view_url=result_link(context, stored, "inventory", demand=demand),
|
|
384
|
+
credit_loops=credit_loops(base),
|
|
385
|
+
linked_credit_loops=linked_credit_loops(linked.values()),
|
|
386
|
+
treatments_run_backwards=await backwards_in(context.inventory, base, linked),
|
|
387
|
+
floor=found.floor,
|
|
388
|
+
dropped=found.dropped,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def register(server: FastMCP, context: ServerContext) -> None:
|
|
393
|
+
"""Register the three calculation tools, in roster order."""
|
|
394
|
+
server.tool(
|
|
395
|
+
name="calculate_impact",
|
|
396
|
+
annotations=READ,
|
|
397
|
+
tags={COMPUTE},
|
|
398
|
+
description=(
|
|
399
|
+
"The characterised impact of a stored product system. Name one method_id from "
|
|
400
|
+
"search_methods, or one impact_method to score all of its categories. Add "
|
|
401
|
+
"variants to compare scenarios or cases against the base under one seed: each "
|
|
402
|
+
"variant answers its point scores and its deltas, its score less the base's, "
|
|
403
|
+
"method by method. A monte_carlo block adds uncertainty and takes at most 200 "
|
|
404
|
+
"draws; start_monte_carlo runs up to 1000 outside the call. Under that block a "
|
|
405
|
+
"pair is the base's and the variant's draws at one position, over the first "
|
|
406
|
+
"draws of each up to the shorter run, since each run's own stop rule ends it, "
|
|
407
|
+
"and paired_deltas holds each difference's median, interval and percentage."
|
|
408
|
+
),
|
|
409
|
+
app=panel("impact"),
|
|
410
|
+
)(boundary(bound(calculate_impact, context)))
|
|
411
|
+
server.tool(
|
|
412
|
+
name="contribution_analysis",
|
|
413
|
+
annotations=READ,
|
|
414
|
+
tags={COMPUTE},
|
|
415
|
+
description=(
|
|
416
|
+
"Decompose one impact. by='process' partitions the total over the processes "
|
|
417
|
+
"that emit it, each appearing once and the parts summing to the total. "
|
|
418
|
+
"by='path' nests it under the demanded process along the supply chain that "
|
|
419
|
+
"caused it, with elementary flows itemised at each node. by='flow' partitions "
|
|
420
|
+
"it over the substances emitted or drawn, ranked by characterised score. Same "
|
|
421
|
+
f"total every way. by='path' stops at max_level (default {MAX_LEVEL}) and "
|
|
422
|
+
f"max_nodes (default {MAX_NODES}) and folds a branch below cutoff (default "
|
|
423
|
+
f"{CUTOFF}) of the total into a remainder; by='process' and by='flow' answer the "
|
|
424
|
+
f'top_n (default {TOP_N}) largest parts. Example: {{"product_system_id": '
|
|
425
|
+
'"…", "by": "path", "method_id": "m_…", "max_level": 4}.'
|
|
426
|
+
),
|
|
427
|
+
app=panel("contribution"),
|
|
428
|
+
)(boundary(bound(contribution_analysis, context)))
|
|
429
|
+
server.tool(
|
|
430
|
+
name="calculate_inventory",
|
|
431
|
+
annotations=READ,
|
|
432
|
+
tags={COMPUTE},
|
|
433
|
+
description=(
|
|
434
|
+
"The life cycle inventory: every elementary flow the demand draws or emits, "
|
|
435
|
+
"with the foreground process that dominates each row. Rows are ordered by "
|
|
436
|
+
"compartment then name and paged; filter by compartment or by words in the "
|
|
437
|
+
"flow name. contribution_analysis by='flow' ranks them by characterised score."
|
|
438
|
+
),
|
|
439
|
+
app=panel("inventory"),
|
|
440
|
+
)(boundary(bound(calculate_inventory, context)))
|