websemantic 0.2.5__py3-none-any.whl
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.
- websemantic/__init__.py +3 -0
- websemantic/adapters/__init__.py +1 -0
- websemantic/adapters/lql.py +118 -0
- websemantic/adapters/pyrcel.py +104 -0
- websemantic/adapters/tls.py +130 -0
- websemantic/adapters/tls_outputs.py +70 -0
- websemantic/cli.py +592 -0
- websemantic/comparison.py +223 -0
- websemantic/conversation.py +44 -0
- websemantic/core/__init__.py +1 -0
- websemantic/core/validation.py +172 -0
- websemantic/environments.json +23 -0
- websemantic/gemini.py +222 -0
- websemantic/geography.py +223 -0
- websemantic/lql_comparison.py +138 -0
- websemantic/qualitative.py +60 -0
- websemantic/registry.py +45 -0
- websemantic/replay.py +68 -0
- websemantic/semantics.py +266 -0
- websemantic/session.py +271 -0
- websemantic/units.py +92 -0
- websemantic/web_research.py +146 -0
- websemantic-0.2.5.dist-info/METADATA +64 -0
- websemantic-0.2.5.dist-info/RECORD +28 -0
- websemantic-0.2.5.dist-info/WHEEL +5 -0
- websemantic-0.2.5.dist-info/entry_points.txt +2 -0
- websemantic-0.2.5.dist-info/licenses/LICENSE +135 -0
- websemantic-0.2.5.dist-info/top_level.txt +1 -0
websemantic/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Thin per-software adapters. The only code allowed to call target simulator APIs."""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Read-only LQL-Equiv execution for one explicitly fictitious course."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from websemantic.core.validation import validate
|
|
15
|
+
from websemantic.semantics import export_semantics
|
|
16
|
+
|
|
17
|
+
EXPECTED_COMMIT = "dfc9a338205b8864b8e3470c4ae245b019e88844"
|
|
18
|
+
# Filled from the pinned backend; line endings do not change numerical sources.
|
|
19
|
+
EXPECTED_FILES = {'__init__.py': '2685b5a34b0da98c8329928f1de533ec6619178c2e2e89e92d48e231ad7a9d82', 'data/__init__.py': 'c849ce7dc4c2cd921e57faf5ffa453f8772eeed07367af69f1f9d7288f1741dd', 'data/tissues.json': '63283344abdc8147da7e112e0a5d728d1dc0bc565ad9950e9e5adccd08027f61', 'model.py': '3e0638c077e2fd9a04fedf7520a8e0c008f88d61b6e28974abd9533d681e0800', 'schedule.py': '7cffecd6e82acf536521aab1c1f58ae997a6754d43185ae6d317752c057f7dfa', 'tissues.py': '9ef2ff4d9a93b040697e5199d6902ce0ca9888474591de56126accf826a05c15'}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _backend(workspace, descriptor):
|
|
23
|
+
if descriptor["software"]["commit"] != EXPECTED_COMMIT:
|
|
24
|
+
raise ValueError("Révision LQL-Equiv incompatible avec cet adaptateur.")
|
|
25
|
+
root = Path(workspace) / "external/LQL-Equiv-web/src/lqlequiv"
|
|
26
|
+
for relative, expected in EXPECTED_FILES.items():
|
|
27
|
+
digest = hashlib.sha256((root / relative).read_bytes().replace(b"\r\n", b"\n")).hexdigest()
|
|
28
|
+
if digest != expected:
|
|
29
|
+
raise ValueError(f"Empreinte LQL-Equiv incorrecte : {relative}.")
|
|
30
|
+
# The library uses importlib.resources on lqlequiv.data. Reject an unrelated
|
|
31
|
+
# installed package instead of silently executing it.
|
|
32
|
+
existing = sys.modules.get("lqlequiv")
|
|
33
|
+
if existing is not None:
|
|
34
|
+
if Path(existing.__file__).resolve() != (root / "__init__.py").resolve():
|
|
35
|
+
raise ValueError("Un autre module lqlequiv est déjà chargé ; redémarrez Python.")
|
|
36
|
+
return existing
|
|
37
|
+
spec = importlib.util.spec_from_file_location(
|
|
38
|
+
"lqlequiv", root / "__init__.py", submodule_search_locations=[str(root)]
|
|
39
|
+
)
|
|
40
|
+
module = importlib.util.module_from_spec(spec)
|
|
41
|
+
sys.modules[spec.name] = module
|
|
42
|
+
try:
|
|
43
|
+
spec.loader.exec_module(module)
|
|
44
|
+
except Exception:
|
|
45
|
+
for key in list(sys.modules):
|
|
46
|
+
if key == "lqlequiv" or key.startswith("lqlequiv."):
|
|
47
|
+
del sys.modules[key]
|
|
48
|
+
raise
|
|
49
|
+
return module
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(scenario, descriptor, workspace, output_root=None):
|
|
53
|
+
"""Return native qualified indicators; never recommend a clinical dose."""
|
|
54
|
+
gate = validate(scenario, descriptor)
|
|
55
|
+
if gate.decision != "execute":
|
|
56
|
+
raise ValueError("Scénario incomplet, non accepté ou hors du profil LQL fictif.")
|
|
57
|
+
config = {name: item.value for name, item in scenario.inputs.items()}
|
|
58
|
+
if scenario.experiment.get("scenario_scope").value != "fictitious":
|
|
59
|
+
raise ValueError("Le profil accepte uniquement des scénarios fictifs de recherche.")
|
|
60
|
+
module = _backend(workspace, descriptor)
|
|
61
|
+
library = module.load_library()
|
|
62
|
+
organ = library.organ(config["organ"])
|
|
63
|
+
tumour = library.tumour_site(config["tumour_site"])
|
|
64
|
+
course = module.Course(config["dose_per_fraction"], config["n_fractions"], config["gap_days"])
|
|
65
|
+
prescription = module.Prescription(
|
|
66
|
+
courses=(course,), reference_dose=config["reference_dose"],
|
|
67
|
+
bifractionated=config["bifractionated"] == "yes",
|
|
68
|
+
)
|
|
69
|
+
result = module.compute(organ, tumour, prescription, options=module.Options())
|
|
70
|
+
native = result.courses[0]
|
|
71
|
+
oar_ok = result.oar_total_valid and not native.oar_saturated
|
|
72
|
+
tumour_ok = result.tumour_total_valid and not native.tumour_saturated
|
|
73
|
+
values = {
|
|
74
|
+
"physical_dose_gy": course.total_dose,
|
|
75
|
+
"bed_oar": native.bed_oar if result.oar_total_valid else None,
|
|
76
|
+
"bed_tumour": native.bed_tumour if result.tumour_total_valid else None,
|
|
77
|
+
"eqd_oar_total": result.eqd_oar_total if oar_ok else None,
|
|
78
|
+
"eqd_tumour_total": result.eqd_tumour_total if tumour_ok else None,
|
|
79
|
+
"ntcp_percent": result.ntcp_percent if oar_ok else None,
|
|
80
|
+
"tcp_percent": result.tcp_percent if tumour_ok else None,
|
|
81
|
+
"cancer_risk": result.cancer_risk if oar_ok else None,
|
|
82
|
+
"overall_days_oar": native.overall_days_oar,
|
|
83
|
+
"overall_days_tumour": native.overall_days_tumour,
|
|
84
|
+
}
|
|
85
|
+
if any(value is not None and not math.isfinite(value) for value in values.values()):
|
|
86
|
+
raise ValueError("Sortie LQL-Equiv non finie ; résultat refusé.")
|
|
87
|
+
flags = {
|
|
88
|
+
"oar_total_valid": result.oar_total_valid, "tumour_total_valid": result.tumour_total_valid,
|
|
89
|
+
"oar_saturated": native.oar_saturated, "tumour_saturated": native.tumour_saturated,
|
|
90
|
+
}
|
|
91
|
+
target = (Path(output_root) if output_root else Path(workspace) / "runs") / (
|
|
92
|
+
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8]
|
|
93
|
+
)
|
|
94
|
+
target.mkdir(parents=True)
|
|
95
|
+
row = {**values, **flags}
|
|
96
|
+
with (target / "indicators.csv").open("w", newline="", encoding="utf-8") as handle:
|
|
97
|
+
writer = csv.DictWriter(handle, fieldnames=list(row))
|
|
98
|
+
writer.writeheader()
|
|
99
|
+
writer.writerow(row)
|
|
100
|
+
qualification = {"tables": {"indicators": {
|
|
101
|
+
"file": "indicators.csv", "aggregation": "Un calcul déterministe, un seul cursus fictif.",
|
|
102
|
+
"columns": {name: {"unit": spec.get("display_unit"), "meaning": spec["definition"]}
|
|
103
|
+
for name, spec in descriptor["outputs"].items()},
|
|
104
|
+
}}}
|
|
105
|
+
manifest = {
|
|
106
|
+
"software": descriptor["software"], "scenario": asdict(scenario),
|
|
107
|
+
"nature": descriptor["nature"], "validity_notes": descriptor["validity_notes"],
|
|
108
|
+
"source_verification": {"method": "sha256_lf", "commit": EXPECTED_COMMIT,
|
|
109
|
+
"files": EXPECTED_FILES},
|
|
110
|
+
"backend_options": asdict(result.options), "tissues": {"organ": asdict(organ), "tumour": asdict(tumour)},
|
|
111
|
+
"flags": flags, "output_qualification": qualification,
|
|
112
|
+
"null_policy": "EQD et probabilités dépendantes supprimées si invalides ou à la borne ; coefficient absent = null.",
|
|
113
|
+
}
|
|
114
|
+
(target / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2,
|
|
115
|
+
allow_nan=False), encoding="utf-8")
|
|
116
|
+
(target / "scenario.json").write_text(json.dumps(asdict(scenario), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
117
|
+
export_semantics(scenario, qualification, target, descriptor)
|
|
118
|
+
return target, values
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Read-only pyrcel 2.0.0 adapter: constant updraft, one lognormal mode."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from websemantic.core.validation import validate
|
|
13
|
+
from websemantic.semantics import export_semantics
|
|
14
|
+
|
|
15
|
+
COMMIT = "977e9094644ec4a17d658d657094651928591c89"
|
|
16
|
+
SOURCE_HASHES = {'pyrcel/aerosol.py': 'd7fc5c1d055a5d6868aae53430a7d1c0797ff4c8f73caff387aef5900fcfc9c4', 'pyrcel/console_report.py': '2f64b98e0bda566582e46a6938c142d1aed361f9f3503f17864b5f153a98bf6e', 'pyrcel/constants.py': '7d75523d46874bb1faeaff3191f7effe27dc8e6e7866976dd72dc7b6d5df52d8', 'pyrcel/distributions.py': '712f41b5e72f54c3cb243d6e7708f8cb014c9f897f97a322ffac1ba832f1eb2e', 'pyrcel/ensemble.py': '856d97c73873061dfeae77c69db5da23d3b15019864808a9180ec6dd66e2641c', 'pyrcel/equilibrate.py': 'c85e21765e25589279d438f405b92bffabeac9115b290bfb76a3f3037cc0ffe7', 'pyrcel/integrator.py': 'd08876ea45151dd0fc2119aec2d4964dc0028087a904da4d7d006723b3e85992', 'pyrcel/model.py': '6c4cf795c682e4acb4b6531b4cd1fc3bf6b0689df9d046625862f0b8ef1ad5f0', 'pyrcel/model_output.py': '7214480542a6248ee914cd0e5709fd5223faf65f022bee3d6eea147ec0f64a3d', 'pyrcel/output.py': 'd7e543ed4d73fd853421f7106b0503dd3991c93a90082adfb180d2935514c472', 'pyrcel/parcel_aux.py': '07d56e3854d16acf309666d1444136fb1c52d5652d29442482a2047eadc91735', 'pyrcel/thermo.py': '779d453484e25b708afda3517f9d8f1303dd40c5ee1ead77c9ac01119cdccd80', 'pyrcel/updraft.py': 'f299171a7da17bbf206fe82b8a15a137e84265c2268b5489d32570e0f5a499ba', 'pyrcel/util.py': '7a1e64350e830870a5582822d2c93518cf18638e723fdc3333299855760db352', 'pyrcel/_types.py': '8e72187aa2c824f7adc4c784dcad676fc082b0487a422809a4619236eeb4561a', 'pyrcel/__init__.py': 'e4a30cbc78b63ef61c5a32f852a27362096c60a97f82c075b290a85acf050639', 'pyrcel/activation/_arg2000.py': 'e107a9c8ac418b09a9e6717bf14d108280ed02c2fc0ac159403cfb45d15f2ead', 'pyrcel/activation/_common.py': '383585cd6c00f2a9a7b940e3b7fa539132aba0981ac67db5f8ad061ec374514c', 'pyrcel/activation/_mbn2014.py': 'dd96adaa9c0282324b072dcf9f51dcac72507c75c0bb85d3cb7ee3f7a9f1a79d', 'pyrcel/activation/_scheme.py': 'f3976646eba72b0b81c4538be92c92fed60c39e4ec7a05bab6e204b9174254c7', 'pyrcel/activation/__init__.py': '9dd0d8a8e2246f6cc8e7aba01bc493c2cad47c9144752908f9a1e251a207cfd2', 'pyrcel/legacy/activation.py': 'f1e283f36d95ae373f705245f46d9beffebaed071a2e4e5ea8af1b81c8b4145d', 'pyrcel/legacy/postprocess.py': '4b763e13f904cd5c0662fbd43dc52e77acbedee61a6aaf9e7d19962216653dbc', 'pyrcel/legacy/thermo.py': '94e21fd06e573ebbadcf7e0ff5b2831f4e06b08f8e02ae185aeaae89e2c61a32', 'pyrcel/legacy/vis.py': '932c8ded03dcf9daeea05a6f9a5c8021841204d92afa32e1dcf85042e9b58a0d', 'pyrcel/legacy/__init__.py': '21ec0430ebb1bfdeef5e73bdf1f4227c23d83bc4d460b94a4f658f23bd45d34a', 'pyrcel/scripts/run_parcel.py': 'd8a45a5f535f469cd8ec6d9bfbc645bd3d28bc1dfc0cdc1f4496a1ccd5d40f4d', 'pyrcel/scripts/__init__.py': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', 'pyrcel/test/generate_data.py': '5fcb1e9f4840c56e4834bbdfa8b3bf25dcd1fe09990f5e24998f3fa3a86165a7', 'pyrcel/test/pm_test.py': 'f0733f17aadccb1620e7c453c46647f9588c39bd7d2f5e79c165ad6671934ad8', 'pyrcel/test/test_thermo.py': '3f6f0bcb7ccec593246c5578fde57c3bcacc63729b3b1bc859da453c2f3b54d3', 'pyrcel/test/__init__.py': 'aedf1b7eccb3cfb02226205f82309a1290079ce31ed5204325781ee2bf368fc7'}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_source(backend):
|
|
20
|
+
"""Verify every Python module in the reviewed package, without requiring Git."""
|
|
21
|
+
actual = {str(p.relative_to(backend)).replace("\\", "/") for p in (backend / "pyrcel").rglob("*.py")}
|
|
22
|
+
if actual != set(SOURCE_HASHES):
|
|
23
|
+
raise ValueError("Modules pyrcel inattendus ou manquants; relancez Installer.")
|
|
24
|
+
for name, expected in SOURCE_HASHES.items():
|
|
25
|
+
digest = hashlib.sha256((backend / name).read_bytes().replace(b"\r\n", b"\n")).hexdigest()
|
|
26
|
+
if digest != expected:
|
|
27
|
+
raise ValueError("Code pyrcel modifié : " + name)
|
|
28
|
+
return {"method": "sha256_lf", "commit": COMMIT, "files": SOURCE_HASHES}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(scenario, descriptor, workspace, output_root=None):
|
|
32
|
+
gate = validate(scenario, descriptor)
|
|
33
|
+
if gate.decision != "execute":
|
|
34
|
+
raise ValueError("Paramètres incomplets ou hypothèses non acceptées : " + "; ".join(i.field + ": " + i.code for i in gate.issues))
|
|
35
|
+
if descriptor["software"]["commit"] != COMMIT:
|
|
36
|
+
raise ValueError("Révision pyrcel incorrecte.")
|
|
37
|
+
params = {k: v.value for k, v in scenario.inputs.items()}
|
|
38
|
+
experiment = {k: v.value for k, v in scenario.experiment.items()}
|
|
39
|
+
if experiment["output_dt"] > experiment["t_end"] or experiment["t_end"] / experiment["output_dt"] > 100000:
|
|
40
|
+
raise ValueError("Pas de sortie incompatible avec la durée ou expérience trop volumineuse.")
|
|
41
|
+
workspace = Path(workspace)
|
|
42
|
+
backend = workspace / "external/pyrcel"
|
|
43
|
+
if not backend.is_dir():
|
|
44
|
+
backend = workspace / "work/candidates/pyrcel"
|
|
45
|
+
verification = verify_source(backend)
|
|
46
|
+
# Must configure before importing JAX; refuse a previously initialised incompatible runtime.
|
|
47
|
+
os.environ["JAX_ENABLE_X64"] = "true"
|
|
48
|
+
os.environ["JAX_PLATFORMS"] = "cpu"
|
|
49
|
+
try:
|
|
50
|
+
import jax
|
|
51
|
+
importlib.import_module("diffrax") # required solver; checked before source import
|
|
52
|
+
except ImportError as exc:
|
|
53
|
+
raise ValueError("Composants pyrcel manquants; lancez Installer.cmd pour installer le profil atmosphérique.") from exc
|
|
54
|
+
jax.config.update("jax_enable_x64", True)
|
|
55
|
+
if any(device.platform != "cpu" for device in jax.devices()):
|
|
56
|
+
raise ValueError("Ce profil nécessite JAX CPU.")
|
|
57
|
+
sys.path.insert(0, str(backend))
|
|
58
|
+
try:
|
|
59
|
+
import pyrcel as pm
|
|
60
|
+
if Path(pm.__file__).resolve() != (backend / "pyrcel/__init__.py").resolve():
|
|
61
|
+
raise ValueError("Un autre module pyrcel est déjà chargé; ouvrez une nouvelle session.")
|
|
62
|
+
import numpy as np
|
|
63
|
+
aerosol = pm.AerosolSpecies("mode1", pm.Lognorm(mu=params["mu"], sigma=params["sigma"], N=params["N"]), kappa=params["kappa"], bins=params["bins"])
|
|
64
|
+
model = pm.ParcelModel([aerosol], V=params["V"], T0=params["T0"], P0=params["P0"], S0=params["S0"], accom=params["accom"], console=False)
|
|
65
|
+
output = model.run(experiment["t_end"], output_dt=experiment["output_dt"], terminate=experiment["terminate"] == "yes", terminate_depth=experiment["terminate_depth"])
|
|
66
|
+
except ImportError as exc:
|
|
67
|
+
raise ValueError("Composants pyrcel manquants; lancez Installer.cmd pour installer le profil atmosphérique.") from exc
|
|
68
|
+
finally:
|
|
69
|
+
sys.path.remove(str(backend))
|
|
70
|
+
if not np.isfinite(output.state).all() or not np.isfinite(output.time).all():
|
|
71
|
+
raise ValueError("Sortie pyrcel non finie; résultat refusé.")
|
|
72
|
+
if len(output.time) < 2 or np.any(np.diff(output.time) <= 0):
|
|
73
|
+
raise ValueError("Support temporel invalide.")
|
|
74
|
+
frame, aerosol_frames = output.to_pandas()
|
|
75
|
+
frame = frame.reset_index()
|
|
76
|
+
frame.rename(columns={frame.columns[0]: "time_s"}, inplace=True)
|
|
77
|
+
if not np.array_equal(frame["time_s"].to_numpy(), output.time):
|
|
78
|
+
raise ValueError("Le support temporel natif n’a pas été conservé.")
|
|
79
|
+
metrics = {"maximum_supersaturation_percent": float(output.summary["S_max"]) * 100, "activated_number_cm3": float(output.Nd) / 1e6, "activated_fraction": float(output.nd_frac), "actual_end_time_s": float(output.time[-1])}
|
|
80
|
+
if not all(np.isfinite(v) for v in metrics.values()):
|
|
81
|
+
raise ValueError("Indicateurs pyrcel non finis.")
|
|
82
|
+
target = (Path(output_root) if output_root else workspace / "runs") / (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8])
|
|
83
|
+
target.mkdir(parents=True)
|
|
84
|
+
frame.to_csv(target / "trajectory.csv", index=False)
|
|
85
|
+
wet = aerosol_frames["mode1"].reset_index()
|
|
86
|
+
wet.rename(columns={wet.columns[0]: "time_s"}, inplace=True)
|
|
87
|
+
wet.to_csv(target / "wet_radii.csv", index=False)
|
|
88
|
+
import pandas as pd
|
|
89
|
+
pd.DataFrame([metrics]).to_csv(target / "kpis.csv", index=False)
|
|
90
|
+
qualification = {"trajectory": {"units": {"time_s": "s", "z": "m", "P": "Pa", "T": "K", "wv": "kg/kg", "wc": "kg/kg", "wi": "kg/kg", "S": "1"}, "temporal_support": "Exact native timestamps; nominal output_dt; final interval may be irregular"}, "wet_radii": {"time_unit": "s", "all_radius_columns_unit": "m", "definition": "Wet particle radius per size bin; not dry diameter"}, "kpis": {"maximum_supersaturation_percent": {"unit": "%", "definition": "Native solver peak locator; may differ from sampled CSV maximum"}, "activated_number_cm3": {"unit": "cm^-3", "definition": "Native hard-activation count at actual final output time"}, "activated_fraction": {"unit": "1", "definition": "Native activation fraction at actual final time"}, "actual_end_time_s": {"unit": "s", "definition": "Actual end; t_end is a maximum"}}}
|
|
91
|
+
qualification['tables'] = {
|
|
92
|
+
'trajectory': {'file': 'trajectory.csv', 'aggregation': 'Pas natif exact, dernier intervalle potentiellement irrégulier.',
|
|
93
|
+
'columns': {name: {'unit': unit, 'meaning': name + ' : sortie native de la parcelle idéale.'} for name, unit in qualification['trajectory']['units'].items()}},
|
|
94
|
+
'wet_radii': {'file': 'wet_radii.csv', 'aggregation': 'Rayons humides par classe aux instants natifs.',
|
|
95
|
+
'columns': {name: {'unit': 's' if name == 'time_s' else 'm', 'meaning': 'Temps natif' if name == 'time_s' else 'Rayon humide, pas diamètre sec'} for name in wet.columns}},
|
|
96
|
+
'kpis': {'file': 'kpis.csv', 'aggregation': 'Indicateurs natifs ; activation à la fin effective.',
|
|
97
|
+
'columns': {name: {'unit': spec['unit'], 'meaning': spec['definition']} for name, spec in qualification['kpis'].items()}}
|
|
98
|
+
}
|
|
99
|
+
export_semantics(scenario, qualification, target, descriptor)
|
|
100
|
+
versions = {name: importlib.metadata.version(name) for name in ("jax", "jaxlib", "diffrax", "equinox", "optimistix", "numpy", "pandas", "scipy")}
|
|
101
|
+
manifest = {"software": descriptor["software"], "source_verification": verification, "scenario": asdict(scenario), "nature": descriptor["nature"], "validity_notes": descriptor["validity_notes"], "output_qualification": qualification, "execution": {"python": sys.version, "versions": versions, "jax_backend": "cpu", "jax_enable_x64": bool(jax.config.jax_enable_x64), "requested_maximum_duration_s": experiment["t_end"], "actual_duration_s": metrics["actual_end_time_s"]}}
|
|
102
|
+
(target / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf8")
|
|
103
|
+
(target / "scenario.json").write_text(json.dumps(asdict(scenario), ensure_ascii=False, indent=2, allow_nan=False), encoding="utf8")
|
|
104
|
+
return target, metrics
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Pinned, read-only TLS adapter with a validation gate at every run."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from websemantic.adapters.tls_outputs import qualify
|
|
14
|
+
from websemantic.core.validation import validate
|
|
15
|
+
from websemantic.semantics import export_semantics
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(scenario, descriptor, workspace, output_root=None):
|
|
19
|
+
result = validate(scenario, descriptor)
|
|
20
|
+
if result.decision != "execute":
|
|
21
|
+
raise ValueError("Configuration non validee : /show puis completer les champs.")
|
|
22
|
+
config = {k: v.value for k, v in scenario.inputs.items()}
|
|
23
|
+
exp = {k: v.value for k, v in scenario.experiment.items()}
|
|
24
|
+
if exp["freq_minutes"] not in (5, 10, 15, 30, 60):
|
|
25
|
+
raise ValueError("Pas temporel autorise : 5, 10, 15, 30, 60 minutes.")
|
|
26
|
+
if exp["n_days"] * 1440 / exp["freq_minutes"] * exp["n_runs"] > 2_000_000:
|
|
27
|
+
raise ValueError("Experience trop volumineuse pour ce PoC local.")
|
|
28
|
+
nonnegative = (
|
|
29
|
+
"max_depth_m",
|
|
30
|
+
"gradient_percent",
|
|
31
|
+
"aux_kw_per_km_tube",
|
|
32
|
+
"base_fixed_kw",
|
|
33
|
+
"traffic_level",
|
|
34
|
+
"traffic_sensitivity",
|
|
35
|
+
"pollution_sensitivity",
|
|
36
|
+
"accident_sensitivity",
|
|
37
|
+
)
|
|
38
|
+
if config["peak_width_h"] <= 0 or any(config[k] < 0 for k in nonnegative):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Largeur des pics positive et charges/sensibilites non negatives requises."
|
|
41
|
+
)
|
|
42
|
+
if any(not 0 <= config[k] < 24 for k in ("morning_peak_hour", "evening_peak_hour")):
|
|
43
|
+
raise ValueError("Heures de pointe attendues entre 0 inclus et 24 exclu.")
|
|
44
|
+
backend = Path(workspace) / "external/tunnel-load-simulator"
|
|
45
|
+
expected_commit = "748e053e129669cf3e896d381e3c0ac01c763edd"
|
|
46
|
+
expected_digest = "8377b8a26e18d060a7a217721100512625f53fbd4d8aa9d7a3b085bd14663464"
|
|
47
|
+
if descriptor["software"]["commit"] != expected_commit:
|
|
48
|
+
raise ValueError("La revision TLS ne correspond pas au descripteur.")
|
|
49
|
+
source_path = backend / "src/tunnel_load_simulator/simulator.py"
|
|
50
|
+
source_bytes = source_path.read_bytes().replace(b"\r\n", b"\n")
|
|
51
|
+
digest = hashlib.sha256(source_bytes).hexdigest()
|
|
52
|
+
if digest != expected_digest:
|
|
53
|
+
raise ValueError("Empreinte du code TLS incorrecte; execution refusee.")
|
|
54
|
+
verification = {"method": "sha256_lf", "commit": expected_commit,
|
|
55
|
+
"file": "src/tunnel_load_simulator/simulator.py", "sha256": digest,
|
|
56
|
+
"scope": "Executed simulator module; CRLF normalized to LF."}
|
|
57
|
+
if (backend / ".git").exists() and shutil.which("git"):
|
|
58
|
+
commit = subprocess.run(
|
|
59
|
+
["git", "-C", str(backend), "rev-parse", "HEAD"],
|
|
60
|
+
capture_output=True, text=True, check=True,
|
|
61
|
+
).stdout.strip()
|
|
62
|
+
if commit != expected_commit:
|
|
63
|
+
raise ValueError("La revision TLS ne correspond pas au descripteur.")
|
|
64
|
+
dirty = subprocess.run(
|
|
65
|
+
["git", "-C", str(backend), "status", "--porcelain", "--untracked-files=no"],
|
|
66
|
+
capture_output=True, text=True, check=True,
|
|
67
|
+
).stdout
|
|
68
|
+
if dirty:
|
|
69
|
+
raise ValueError("Le backend comporte des modifications suivies; execution refusee.")
|
|
70
|
+
verification["method"] = "git_and_sha256_lf"
|
|
71
|
+
import pandas as pd
|
|
72
|
+
|
|
73
|
+
spec = importlib.util.spec_from_file_location(
|
|
74
|
+
"websemantic_pinned_tls", backend / "src/tunnel_load_simulator/simulator.py"
|
|
75
|
+
)
|
|
76
|
+
module = importlib.util.module_from_spec(spec)
|
|
77
|
+
import sys
|
|
78
|
+
|
|
79
|
+
sys.modules[spec.name] = module
|
|
80
|
+
spec.loader.exec_module(module)
|
|
81
|
+
outputs = module.run_monte_carlo(
|
|
82
|
+
module.TunnelConfig(**config),
|
|
83
|
+
pd.Timestamp(exp["start_date"]),
|
|
84
|
+
exp["n_days"],
|
|
85
|
+
exp["freq_minutes"],
|
|
86
|
+
exp["n_runs"],
|
|
87
|
+
exp["base_seed"],
|
|
88
|
+
)
|
|
89
|
+
# Add explicit operational provenance without changing numerical indicators.
|
|
90
|
+
outputs["kpis"]["base_seed"] = exp["base_seed"]
|
|
91
|
+
# Preserve native TLS trajectories; derive daily energy from its first trajectory.
|
|
92
|
+
outputs["daily"] = (
|
|
93
|
+
outputs["representative"].set_index("timestamp")
|
|
94
|
+
.resample("1D").agg({"energy_kwh": "sum", "power_kw": "mean"})
|
|
95
|
+
.rename(columns={"power_kw": "mean_kw"}).reset_index()
|
|
96
|
+
)
|
|
97
|
+
qualification = qualify(outputs, exp)
|
|
98
|
+
target = (
|
|
99
|
+
(Path(output_root) if output_root else Path(workspace) / "runs")
|
|
100
|
+
/ (
|
|
101
|
+
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
102
|
+
+ "-"
|
|
103
|
+
+ uuid.uuid4().hex[:8]
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
target.mkdir(parents=True)
|
|
107
|
+
for name, table in outputs.items():
|
|
108
|
+
table.to_csv(target / f"{name}.csv", index=False)
|
|
109
|
+
manifest = {
|
|
110
|
+
"software": descriptor["software"],
|
|
111
|
+
"source_verification": verification,
|
|
112
|
+
"scenario": asdict(scenario),
|
|
113
|
+
"nature": descriptor["nature"],
|
|
114
|
+
"uncertainty": descriptor["uncertainty"],
|
|
115
|
+
"validity_notes": descriptor["validity_notes"],
|
|
116
|
+
"outputs": list(outputs),
|
|
117
|
+
"output_qualification": qualification,
|
|
118
|
+
"note": "Synthetic simulation outputs.",
|
|
119
|
+
}
|
|
120
|
+
(target / "manifest.json").write_text(
|
|
121
|
+
json.dumps(manifest, ensure_ascii=False, indent=2, allow_nan=False),
|
|
122
|
+
encoding="utf-8",
|
|
123
|
+
)
|
|
124
|
+
export_semantics(scenario, qualification, target, descriptor)
|
|
125
|
+
medians = (
|
|
126
|
+
outputs["kpis"][["total_mwh", "annualized_mwh", "peak_kw", "load_factor", "specific_kwh_m_year"]]
|
|
127
|
+
.median()
|
|
128
|
+
.to_dict()
|
|
129
|
+
)
|
|
130
|
+
return target, medians
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Output definitions checked against the pinned TLS implementation."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def column_metadata(name, table):
|
|
5
|
+
if table == "daily" and name == "energy_kwh":
|
|
6
|
+
return {"quantity": "energy", "unit": "kWh", "meaning": "Somme des énergies des pas natifs de la journée, réalisation 0."}
|
|
7
|
+
if table == "daily" and name == "mean_kw":
|
|
8
|
+
return {"quantity": "power", "unit": "kW", "meaning": "Moyenne des puissances des pas natifs de la journée, réalisation 0."}
|
|
9
|
+
if name.endswith("_kw") or (table == "envelope" and name in ("median", "p10", "p90", "mean")):
|
|
10
|
+
return {"quantity": "power", "unit": "kW", "meaning": "Puissance électrique calculée."}
|
|
11
|
+
definitions = {
|
|
12
|
+
"energy_kwh": ("energy", "kWh", "Énergie du pas : power_kw × freq_minutes / 60."),
|
|
13
|
+
"total_mwh": ("energy", "MWh", "Énergie de toute la période : somme des energy_kwh / 1000."),
|
|
14
|
+
"annualized_mwh": ("annualized_energy", "MWh/an", "Extrapolation : total_mwh × 365 / n_days ; pas une année observée."),
|
|
15
|
+
"specific_kwh_m_year": ("length_normalized_annualized_energy", "kWh/(m·an)", "annualized_mwh × 1000 / length_m ; ensemble des tubes, par mètre de longueur du tunnel, pas par mètre-tube."),
|
|
16
|
+
"load_factor": ("power_ratio", "1", "mean_kw / peak_kw par réalisation ; sans dimension, 0,6 équivaut à 60 %."),
|
|
17
|
+
"timestamp": ("time", "ISO 8601", "Horodatage sans fuseau horaire déclaré ; aucune localisation déduite."),
|
|
18
|
+
"hour_int": ("hour_of_day", "h", "Heure du jour, de 0 à 23 ; pas une durée."),
|
|
19
|
+
"dayofweek": ("weekday_index", "1", "Indice : lundi 0, dimanche 6."),
|
|
20
|
+
"traffic_index": ("relative_traffic_index", "1", "Indice relatif de trafic ; pas un nombre de véhicules par jour."),
|
|
21
|
+
"pollution_event": ("event_flag", "1", "Indicateur binaire d'événement de pollution simulé."),
|
|
22
|
+
"accident_event": ("event_flag", "1", "Indicateur binaire d'accident simulé."),
|
|
23
|
+
"n_pollution_events": ("transition_count", "1", "Nombre de transitions 0 vers 1 dans la série pollution_event ; un événement actif au premier pas n'est pas compté."),
|
|
24
|
+
"n_accident_events": ("transition_count", "1", "Nombre de transitions 0 vers 1 dans la série accident_event ; un événement actif au premier pas n'est pas compté."),
|
|
25
|
+
"run": ("identifier", None, "Identifiant de réalisation Monte Carlo, à partir de 0."),
|
|
26
|
+
"base_seed": ("identifier", None, "Graine de base fixe de l’expérience ; 42 par défaut, modifiable explicitement."),
|
|
27
|
+
"seed": ("identifier", None, "Graine pseudo-aléatoire : base_seed + run."),
|
|
28
|
+
"season": ("category", None, "Saison du calendrier simulé ; ne prouve pas une couverture annuelle."),
|
|
29
|
+
"day_type": ("category", None, "Jour de semaine ou week-end du calendrier simulé."),
|
|
30
|
+
}
|
|
31
|
+
if name not in definitions:
|
|
32
|
+
raise ValueError(f"Sortie TLS non qualifiée : {table}.{name}")
|
|
33
|
+
quantity, unit, meaning = definitions[name]
|
|
34
|
+
return {"quantity": quantity, "unit": unit, "meaning": meaning}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def qualify(outputs, experiment):
|
|
38
|
+
aggregations = {
|
|
39
|
+
"daily": "Agrégation journalière de la réalisation 0 : somme de l'énergie et moyenne de la puissance ; pas une médiane Monte Carlo. Les dates suivent le calendrier sans fuseau déclaré.",
|
|
40
|
+
"representative": "Réalisation 0 au pas natif ; exemple de trajectoire, pas la médiane.",
|
|
41
|
+
"kpis": "Une ligne par réalisation sur toute la période simulée.",
|
|
42
|
+
"envelope": "Puissances moyennées par heure dans chaque réalisation, puis statistiques entre réalisations. p10/p90 sont des quantiles empiriques, pas un intervalle de confiance.",
|
|
43
|
+
"season_profiles": "Puissance moyenne par saison et heure du jour, dans chaque réalisation, uniquement sur les jours simulés.",
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
"schema_version": "tls-output-qualification-1",
|
|
47
|
+
"data_origin": "synthetic_simulation",
|
|
48
|
+
"spatial_scope": "Ensemble du tunnel et de ses tubes ; aucune coordonnée géographique déclarée.",
|
|
49
|
+
"temporal_scope": {
|
|
50
|
+
"start_date": experiment["start_date"],
|
|
51
|
+
"duration_days": experiment["n_days"],
|
|
52
|
+
"native_step_minutes": experiment["freq_minutes"],
|
|
53
|
+
"timezone": None,
|
|
54
|
+
},
|
|
55
|
+
"monte_carlo": {"realizations": experiment["n_runs"], "base_seed": experiment["base_seed"]},
|
|
56
|
+
"fitness_for_use": {
|
|
57
|
+
"admitted": "Exploration des sorties du modèle sous les hypothèses déclarées.",
|
|
58
|
+
"excluded": "Certification, consommation réelle sans calibration, classement fort/faible non paramétré.",
|
|
59
|
+
"evidence_status": "Contrôles logiciels et calculs ; aucune validation terrain.",
|
|
60
|
+
},
|
|
61
|
+
"tables": {
|
|
62
|
+
name: {
|
|
63
|
+
"file": f"{name}.csv",
|
|
64
|
+
"aggregation": aggregations[name],
|
|
65
|
+
"rows": len(table),
|
|
66
|
+
"columns": {column: column_metadata(column, name) for column in table.columns},
|
|
67
|
+
}
|
|
68
|
+
for name, table in outputs.items()
|
|
69
|
+
},
|
|
70
|
+
}
|