python-materialsdb 0.0.2__py3-none-any.whl → 0.2.0__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.
- materialsdb/__init__.py +3 -1
- materialsdb/cache.py +5 -6
- materialsdb/classes.py +14 -6
- materialsdb/config.py +3 -6
- materialsdb/construction.py +441 -0
- materialsdb/gui/__init__.py +0 -0
- materialsdb/gui/__main__.py +32 -0
- materialsdb/gui/discovery.py +28 -0
- materialsdb/gui/listener.py +151 -0
- materialsdb/gui/server.py +730 -0
- materialsdb/gui/static/app-constructions.js +639 -0
- materialsdb/gui/static/app.js +451 -0
- materialsdb/gui/static/constructions.html +107 -0
- materialsdb/gui/static/index.html +56 -0
- materialsdb/gui/static/picker-core.js +111 -0
- materialsdb/ifc/material_builder.py +295 -0
- materialsdb/ifc/material_psets.json +594 -580
- materialsdb/ifc/project_library.py +77 -212
- materialsdb/query.py +24 -0
- materialsdb/schema/MaterialsDBIndex100.xsd +60 -0
- materialsdb/schema/materialsdb102.xsd +820 -0
- materialsdb/schema/materialsdb103.xsd +938 -0
- materialsdb/serialiser.py +45 -18
- materialsdb/store.py +293 -0
- materialsdb/summary.py +83 -0
- materialsdb/utils.py +15 -21
- python_materialsdb-0.2.0.dist-info/METADATA +170 -0
- python_materialsdb-0.2.0.dist-info/RECORD +33 -0
- {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info}/WHEEL +1 -1
- python_materialsdb-0.2.0.dist-info/entry_points.txt +2 -0
- python_materialsdb-0.0.2.dist-info/METADATA +0 -68
- python_materialsdb-0.0.2.dist-info/RECORD +0 -14
- {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info/licenses}/LICENSE.md +0 -0
- {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info}/top_level.txt +0 -0
materialsdb/__init__.py
CHANGED
materialsdb/cache.py
CHANGED
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import pathlib
|
|
3
3
|
import urllib.request
|
|
4
4
|
from collections import namedtuple
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
from lxml import etree
|
|
7
7
|
|
|
8
8
|
MATERIALSDBINDEXURLLIST = [
|
|
@@ -13,9 +13,7 @@ MATERIALSDBINDEXURLLIST = [
|
|
|
13
13
|
|
|
14
14
|
def get_cache_folder():
|
|
15
15
|
cache_dir = pathlib.Path(
|
|
16
|
-
os.environ.get("APPDATA")
|
|
17
|
-
or os.environ.get("XDG_CACHE_HOME")
|
|
18
|
-
or pathlib.Path.home() / ".cache"
|
|
16
|
+
os.environ.get("APPDATA") or os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".cache"
|
|
19
17
|
).joinpath(
|
|
20
18
|
"materialsdb",
|
|
21
19
|
)
|
|
@@ -35,7 +33,7 @@ def parse_cached_index(index) -> etree._ElementTree:
|
|
|
35
33
|
return etree.ElementTree(root)
|
|
36
34
|
|
|
37
35
|
|
|
38
|
-
def get_by_id(root: etree._Element, id: str) ->
|
|
36
|
+
def get_by_id(root: etree._Element, id: str) -> etree._Element | None:
|
|
39
37
|
for company in root:
|
|
40
38
|
if company.get("id") == id:
|
|
41
39
|
return company
|
|
@@ -75,7 +73,8 @@ def update_producers_data(url_list=MATERIALSDBINDEXURLLIST):
|
|
|
75
73
|
def update_producers_from_index(index):
|
|
76
74
|
cached_index = parse_cached_index(index)
|
|
77
75
|
cached_root = cached_index.getroot()
|
|
78
|
-
|
|
76
|
+
with urllib.request.urlopen(index) as response:
|
|
77
|
+
new_index = etree.parse(response)
|
|
79
78
|
new_root = new_index.getroot()
|
|
80
79
|
producers_dir = get_producers_dir()
|
|
81
80
|
has_index_update = False
|
materialsdb/classes.py
CHANGED
|
@@ -14,6 +14,12 @@ from dataclasses import dataclass
|
|
|
14
14
|
from typing import Tuple, Optional, List
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class TDesignUsage(str):
|
|
18
|
+
xs_type: str = "simpleType"
|
|
19
|
+
xml_enum: Tuple[str, ...] = ('consDesignForWall', 'consDesignForRoof', 'consDesignForFloor')
|
|
20
|
+
xml_name: str = "TDesignUsage"
|
|
21
|
+
|
|
22
|
+
|
|
17
23
|
class TLCADB(str):
|
|
18
24
|
xs_type: str = "simpleType"
|
|
19
25
|
xml_enum: Tuple[str, ...] = ('dbKBOB', 'dbOkobauDat', 'dbLux')
|
|
@@ -58,7 +64,7 @@ class T2Lowercase(str):
|
|
|
58
64
|
|
|
59
65
|
class Groupkind(str):
|
|
60
66
|
xs_type: str = "simpleType"
|
|
61
|
-
xml_enum: Tuple[str, ...] = ('Others', 'Water_Proof', 'Vapour_Proof', 'Concrete', 'Wood_Timberproducts', 'Insulation', 'Masonry', 'Metal', 'Mortar', 'Plastics', 'Stone', 'Composite', 'Films', 'Render', 'Covering', 'Glas', 'Soil', '
|
|
67
|
+
xml_enum: Tuple[str, ...] = ('Others', 'Water_Proof', 'Vapour_Proof', 'Concrete', 'Wood_Timberproducts', 'Insulation', 'Masonry', 'Metal', 'Mortar', 'Plastics', 'Stone', 'Composite', 'Films', 'Render', 'Covering', 'Glas', 'Soil', 'Air')
|
|
62
68
|
xml_name: str = "groupkind"
|
|
63
69
|
|
|
64
70
|
|
|
@@ -408,9 +414,10 @@ class Lcia:
|
|
|
408
414
|
@dataclass
|
|
409
415
|
class Lcaversion:
|
|
410
416
|
id: str
|
|
411
|
-
code: Brackguid
|
|
412
417
|
xs_type: str = "element"
|
|
413
|
-
|
|
418
|
+
code: Optional[Brackguid] = None
|
|
419
|
+
disabled: Optional[Boolean] = None
|
|
420
|
+
xml_attributes: Tuple[str, ...] = ('id', 'code', 'disabled')
|
|
414
421
|
xml_name = "lcaversion"
|
|
415
422
|
xml_elements: Tuple[str, ...] = ()
|
|
416
423
|
|
|
@@ -555,11 +562,11 @@ class Vlca:
|
|
|
555
562
|
@dataclass
|
|
556
563
|
class Variation:
|
|
557
564
|
id: Guid
|
|
558
|
-
vgeometry: List[Vgeometry]
|
|
559
|
-
vthermal: List[Vthermal]
|
|
560
565
|
xs_type: str = "element"
|
|
561
566
|
displayorder: Optional[int] = None
|
|
562
567
|
xml_attributes: Tuple[str, ...] = ('id', 'displayorder')
|
|
568
|
+
vgeometry: Optional[List[Vgeometry]] = None
|
|
569
|
+
vthermal: Optional[List[Vthermal]] = None
|
|
563
570
|
vacoustic: Optional[List[Vacoustic]] = None
|
|
564
571
|
vother: Optional[List[Vother]] = None
|
|
565
572
|
vlcia: Optional[List[Vlcia]] = None
|
|
@@ -582,7 +589,8 @@ class Construction:
|
|
|
582
589
|
xs_type: str = "element"
|
|
583
590
|
source: Optional[str] = None
|
|
584
591
|
consref: Optional[str] = None
|
|
585
|
-
|
|
592
|
+
designusage: Optional[TDesignUsage] = None
|
|
593
|
+
xml_attributes: Tuple[str, ...] = ('source', 'consref', 'designusage')
|
|
586
594
|
xml_name = "construction"
|
|
587
595
|
xml_elements: Tuple[str, ...] = ()
|
|
588
596
|
|
materialsdb/config.py
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
import pathlib
|
|
3
|
-
import json
|
|
4
|
-
from typing import Dict
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
def get_config_dir():
|
|
8
7
|
config_dir = pathlib.Path(
|
|
9
|
-
os.environ.get("APPDATA")
|
|
10
|
-
or os.environ.get("XDG_CACHE_HOME")
|
|
11
|
-
or pathlib.Path.home() / ".config"
|
|
8
|
+
os.environ.get("APPDATA") or os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".config"
|
|
12
9
|
).joinpath(
|
|
13
10
|
"materialsdb",
|
|
14
11
|
)
|
|
@@ -20,7 +17,7 @@ def get_base_config_path() -> pathlib.Path:
|
|
|
20
17
|
return get_config_dir() / "config.json"
|
|
21
18
|
|
|
22
19
|
|
|
23
|
-
def get_base_config() ->
|
|
20
|
+
def get_base_config() -> dict[str, str]:
|
|
24
21
|
base_config_path = get_base_config_path()
|
|
25
22
|
if base_config_path.exists():
|
|
26
23
|
return json.loads(get_base_config_path().read_text("utf-8"))
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Thermal construction composition: stack model, U-value math, IFC emission."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from materialsdb import cache, config, utils
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def finite_or_none(value) -> float | None:
|
|
14
|
+
try:
|
|
15
|
+
number = float(value)
|
|
16
|
+
except (TypeError, ValueError):
|
|
17
|
+
return None
|
|
18
|
+
return number if math.isfinite(number) else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Surface resistances (m2K/W): interior/exterior by heat-flow direction.
|
|
22
|
+
# ISO 6946 table values; SIA 180 references the same table for these boundary
|
|
23
|
+
# cases (verified against secondary literature 2026-08). Kept as separate
|
|
24
|
+
# entries so standard-specific corrections have an explicit home.
|
|
25
|
+
RESISTANCE_PRESETS = {
|
|
26
|
+
"ISO6946": {
|
|
27
|
+
"wall": (0.13, 0.04),
|
|
28
|
+
"roof": (0.10, 0.04),
|
|
29
|
+
"floor": (0.17, 0.04),
|
|
30
|
+
"generic": (0.13, 0.04),
|
|
31
|
+
},
|
|
32
|
+
"SIA180": {
|
|
33
|
+
"wall": (0.13, 0.04),
|
|
34
|
+
"roof": (0.10, 0.04),
|
|
35
|
+
"floor": (0.17, 0.04),
|
|
36
|
+
"generic": (0.13, 0.04),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_DESIGN_USAGE_TO_DIRECTION = {
|
|
41
|
+
"consDesignForWall": "wall",
|
|
42
|
+
"consDesignForRoof": "roof",
|
|
43
|
+
"consDesignForFloor": "floor",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ConstructionLayer:
|
|
49
|
+
material_id: str | None
|
|
50
|
+
thickness_m: float
|
|
51
|
+
# {"name": str, "lambda_value": float | None} for model materials without
|
|
52
|
+
# a materialsdb identity (round-tripped from Bonsai, re-attached by name)
|
|
53
|
+
placeholder: dict | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Construction:
|
|
58
|
+
name: str
|
|
59
|
+
design_usage: str | None
|
|
60
|
+
layers: list[ConstructionLayer] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class UResult:
|
|
65
|
+
u: float | None
|
|
66
|
+
rsi: float
|
|
67
|
+
rse: float
|
|
68
|
+
contributions: list[dict]
|
|
69
|
+
missing_lambda_ids: list[str | None]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_lambda(store_, material_id: str | None, country: str | None = None) -> float | None:
|
|
73
|
+
"""First country-resolved lambda_value across the material's layers."""
|
|
74
|
+
material = store_.get(material_id)
|
|
75
|
+
if material is None:
|
|
76
|
+
return None
|
|
77
|
+
country = country or config.get_country()
|
|
78
|
+
for layer in getattr(getattr(material, "layers", None), "layer", ()) or ():
|
|
79
|
+
thermal = utils.get_by_country(layer.thermal or (), country)
|
|
80
|
+
if thermal is not None and thermal.lambda_value is not None:
|
|
81
|
+
return thermal.lambda_value
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _direction(design_usage: str | None) -> str:
|
|
86
|
+
return _DESIGN_USAGE_TO_DIRECTION.get(design_usage or "", "generic")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def u_value(construction: Construction, store_, preset: str = "ISO6946") -> UResult:
|
|
90
|
+
if preset not in RESISTANCE_PRESETS:
|
|
91
|
+
raise ValueError(f"unknown preset: {preset} (available: {sorted(RESISTANCE_PRESETS)})")
|
|
92
|
+
direction = _direction(construction.design_usage)
|
|
93
|
+
rsi, rse = RESISTANCE_PRESETS[preset][direction]
|
|
94
|
+
country = config.get_country()
|
|
95
|
+
|
|
96
|
+
contributions = []
|
|
97
|
+
missing = []
|
|
98
|
+
r_sum = 0.0
|
|
99
|
+
for index, layer in enumerate(construction.layers):
|
|
100
|
+
if layer.placeholder is not None:
|
|
101
|
+
name = layer.placeholder.get("name") or ""
|
|
102
|
+
lambda_value = finite_or_none(layer.placeholder.get("lambda_value"))
|
|
103
|
+
else:
|
|
104
|
+
summary = store_.get_summary(layer.material_id)
|
|
105
|
+
if summary is not None:
|
|
106
|
+
name = summary.names.get(config.get_lang()) or summary.names.get("") or ""
|
|
107
|
+
lambda_value = resolve_lambda(store_, layer.material_id, country)
|
|
108
|
+
if lambda_value is None or lambda_value <= 0 or not layer.thickness_m or layer.thickness_m <= 0:
|
|
109
|
+
missing.append(layer.material_id)
|
|
110
|
+
continue
|
|
111
|
+
r_layer = layer.thickness_m / lambda_value
|
|
112
|
+
r_sum += r_layer
|
|
113
|
+
contributions.append(
|
|
114
|
+
{
|
|
115
|
+
"material_id": layer.material_id,
|
|
116
|
+
"name": name,
|
|
117
|
+
"d_m": layer.thickness_m,
|
|
118
|
+
"lambda_value": lambda_value,
|
|
119
|
+
"r": r_layer,
|
|
120
|
+
"layer_index": index,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if missing or not contributions or math.isclose(r_sum, 0):
|
|
125
|
+
return UResult(u=None, rsi=rsi, rse=rse, contributions=contributions, missing_lambda_ids=missing)
|
|
126
|
+
|
|
127
|
+
u = 1 / (rsi + r_sum + rse)
|
|
128
|
+
return UResult(u=u, rsi=rsi, rse=rse, contributions=contributions, missing_lambda_ids=missing)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _has_identity_pset(file, material) -> bool:
|
|
132
|
+
from materialsdb.ifc.material_builder import MATERIALSDB_PSET, _materials_of
|
|
133
|
+
|
|
134
|
+
for pset in file.by_type("IfcMaterialProperties"):
|
|
135
|
+
if pset.Name != MATERIALSDB_PSET:
|
|
136
|
+
continue
|
|
137
|
+
if any(m.id() == material.id() for m in _materials_of(pset)):
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _purge_prior_layer_sets(file, name: str) -> None:
|
|
143
|
+
"""Remove prior IfcMaterialLayerSets called `name` before a re-append.
|
|
144
|
+
|
|
145
|
+
Layers whose material carries a materialsdb identity pset are ours: their
|
|
146
|
+
materials are purged outright. Foreign materials (no identity pset) are
|
|
147
|
+
kept and merely detached from their layer references; emptied set shells
|
|
148
|
+
are dropped so the replacement set stays unique."""
|
|
149
|
+
from materialsdb.ifc.material_builder import purge_material
|
|
150
|
+
|
|
151
|
+
stale = []
|
|
152
|
+
for old_set in [s for s in file.by_type("IfcMaterialLayerSet") if s.LayerSetName == name]:
|
|
153
|
+
for layer in list(old_set.MaterialLayers or ()):
|
|
154
|
+
material = getattr(layer, "Material", None)
|
|
155
|
+
if material is not None and _has_identity_pset(file, material):
|
|
156
|
+
stale.append(material.id())
|
|
157
|
+
for guid in dict.fromkeys(stale):
|
|
158
|
+
purge_material(file, guid)
|
|
159
|
+
# re-fetch: purge may already have removed some or all of the old sets
|
|
160
|
+
for leftover in [s for s in file.by_type("IfcMaterialLayerSet") if s.LayerSetName == name]:
|
|
161
|
+
for layer in list(leftover.MaterialLayers or ()):
|
|
162
|
+
file.remove(layer)
|
|
163
|
+
if not leftover.MaterialLayers:
|
|
164
|
+
file.remove(leftover)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def to_ifc_layer_set(construction: Construction, store_, file=None):
|
|
168
|
+
"""Emit a wrapper IFC library containing the construction as an
|
|
169
|
+
IfcMaterialLayerSet. Referenced materials are built through
|
|
170
|
+
MaterialBuilder (single representative variant) so their identity psets
|
|
171
|
+
ride along; IfcMaterialLayer.Description carries the source material guid.
|
|
172
|
+
|
|
173
|
+
Appending into an existing session file replaces any prior layer set with
|
|
174
|
+
the same name (materials matched by their materialsdb identity)."""
|
|
175
|
+
if any(layer.placeholder is not None for layer in construction.layers):
|
|
176
|
+
raise ValueError("cannot export placeholder layers; assign materialsdb materials first")
|
|
177
|
+
import uuid
|
|
178
|
+
|
|
179
|
+
from materialsdb.ifc.material_builder import MaterialBuilder
|
|
180
|
+
from materialsdb.ifc.project_library import ProjectLibrary
|
|
181
|
+
|
|
182
|
+
missing = [layer.material_id for layer in construction.layers if store_.get(layer.material_id) is None]
|
|
183
|
+
if missing:
|
|
184
|
+
raise ValueError(f"unknown material ids: {', '.join(str(m) for m in missing)}")
|
|
185
|
+
|
|
186
|
+
if file is None:
|
|
187
|
+
library = ProjectLibrary()
|
|
188
|
+
library.create_project_library(
|
|
189
|
+
company="MaterialsDB Constructions",
|
|
190
|
+
companyid=str(uuid.uuid4()),
|
|
191
|
+
ver=1,
|
|
192
|
+
crd=utils.new_tdatetime(),
|
|
193
|
+
)
|
|
194
|
+
target_file = library.file
|
|
195
|
+
else:
|
|
196
|
+
library = None
|
|
197
|
+
target_file = file
|
|
198
|
+
# purge before building: a later find_existing must not re-attach to
|
|
199
|
+
# materials that are about to be removed with the superseded set
|
|
200
|
+
_purge_prior_layer_sets(target_file, construction.name)
|
|
201
|
+
|
|
202
|
+
builder = MaterialBuilder(target_file)
|
|
203
|
+
material_layers = []
|
|
204
|
+
for layer in construction.layers:
|
|
205
|
+
summary = store_.get_summary(layer.material_id)
|
|
206
|
+
material = store_.get(layer.material_id)
|
|
207
|
+
created = builder.build(
|
|
208
|
+
material,
|
|
209
|
+
company_id=str(summary.company_id),
|
|
210
|
+
company=summary.company,
|
|
211
|
+
with_layers=False,
|
|
212
|
+
)
|
|
213
|
+
assert len(created) == 1, "with_layers=False must yield exactly one IfcMaterial"
|
|
214
|
+
name = summary.names.get(config.get_lang()) or ""
|
|
215
|
+
element_name = f"{name} | {round(layer.thickness_m * 1000)} mm"
|
|
216
|
+
ifc_layer = target_file.create_entity(
|
|
217
|
+
"IfcMaterialLayer",
|
|
218
|
+
Material=created[0],
|
|
219
|
+
LayerThickness=layer.thickness_m,
|
|
220
|
+
Description=layer.material_id,
|
|
221
|
+
Name=element_name,
|
|
222
|
+
)
|
|
223
|
+
material_layers.append(ifc_layer)
|
|
224
|
+
|
|
225
|
+
target_file.create_entity(
|
|
226
|
+
"IfcMaterialLayerSet",
|
|
227
|
+
MaterialLayers=material_layers,
|
|
228
|
+
LayerSetName=construction.name,
|
|
229
|
+
)
|
|
230
|
+
return target_file if library is None else library.file
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def constructions_dir() -> Path:
|
|
234
|
+
directory = cache.get_cache_folder() / "constructions"
|
|
235
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
return directory
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def slugify(name: str) -> str:
|
|
240
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
241
|
+
return slug or "construction"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _unique_path(directory: Path, base: str) -> Path:
|
|
245
|
+
candidate = directory / f"{base}.json"
|
|
246
|
+
index = 2
|
|
247
|
+
while candidate.exists():
|
|
248
|
+
candidate = directory / f"{base}-{index}.json"
|
|
249
|
+
index += 1
|
|
250
|
+
return candidate
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _find_stored_name_file(directory: Path, name: str) -> Path | None:
|
|
254
|
+
for file in directory.glob("*.json"):
|
|
255
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
256
|
+
if data.get("name") == name:
|
|
257
|
+
return file
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_construction(body: dict, store_) -> tuple[Construction, list[str]]:
|
|
262
|
+
problems: list[str] = []
|
|
263
|
+
name = str(body.get("name") or "").strip()
|
|
264
|
+
if not name:
|
|
265
|
+
problems.append("name required")
|
|
266
|
+
layers_body = body.get("layers")
|
|
267
|
+
if not isinstance(layers_body, list) or not layers_body:
|
|
268
|
+
problems.append("at least one layer required")
|
|
269
|
+
layers_body = []
|
|
270
|
+
layers = []
|
|
271
|
+
for index, entry in enumerate(layers_body):
|
|
272
|
+
if not isinstance(entry, dict):
|
|
273
|
+
problems.append(f"layer {index}: invalid entry")
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
thickness = float(entry.get("thickness_m"))
|
|
277
|
+
except (TypeError, ValueError):
|
|
278
|
+
problems.append(f"layer {index}: thickness must be a number")
|
|
279
|
+
continue
|
|
280
|
+
if not math.isfinite(thickness) or thickness <= 0:
|
|
281
|
+
problems.append(f"layer {index}: thickness must be > 0")
|
|
282
|
+
continue
|
|
283
|
+
material_id = entry.get("material_id")
|
|
284
|
+
placeholder = entry.get("placeholder") if isinstance(entry.get("placeholder"), dict) else None
|
|
285
|
+
if material_id:
|
|
286
|
+
if store_.get(str(material_id)) is None:
|
|
287
|
+
problems.append(f"unknown material id: {material_id}")
|
|
288
|
+
continue
|
|
289
|
+
layers.append(ConstructionLayer(material_id=str(material_id), thickness_m=thickness))
|
|
290
|
+
elif placeholder is not None:
|
|
291
|
+
layers.append(
|
|
292
|
+
ConstructionLayer(
|
|
293
|
+
material_id=None,
|
|
294
|
+
thickness_m=thickness,
|
|
295
|
+
placeholder={
|
|
296
|
+
"name": str(placeholder.get("name") or ""),
|
|
297
|
+
"lambda_value": finite_or_none(placeholder.get("lambda_value")),
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
problems.append(f"layer {index}: material_id or placeholder required")
|
|
303
|
+
design_usage = body.get("design_usage") or None
|
|
304
|
+
if design_usage not in (None, *_DESIGN_USAGE_TO_DIRECTION):
|
|
305
|
+
problems.append(f"invalid design_usage: {design_usage}")
|
|
306
|
+
return Construction(name=name, design_usage=design_usage, layers=layers), problems
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _to_body(construction: Construction) -> dict:
|
|
310
|
+
return {
|
|
311
|
+
"name": construction.name,
|
|
312
|
+
"design_usage": construction.design_usage,
|
|
313
|
+
"layers": [
|
|
314
|
+
{
|
|
315
|
+
"material_id": layer.material_id,
|
|
316
|
+
"thickness_m": layer.thickness_m,
|
|
317
|
+
**({"placeholder": layer.placeholder} if layer.placeholder else {}),
|
|
318
|
+
}
|
|
319
|
+
for layer in construction.layers
|
|
320
|
+
],
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def save_construction(construction: Construction, store_) -> Path:
|
|
325
|
+
_, problems = validate_construction(_to_body(construction), store_)
|
|
326
|
+
if problems:
|
|
327
|
+
raise ValueError("; ".join(problems))
|
|
328
|
+
directory = constructions_dir()
|
|
329
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
existing = _find_stored_name_file(directory, construction.name)
|
|
331
|
+
path = existing if existing is not None else _unique_path(directory, slugify(construction.name))
|
|
332
|
+
payload = {
|
|
333
|
+
"name": construction.name,
|
|
334
|
+
"design_usage": construction.design_usage,
|
|
335
|
+
"layers": [
|
|
336
|
+
{
|
|
337
|
+
"material_id": layer.material_id,
|
|
338
|
+
"thickness_m": layer.thickness_m,
|
|
339
|
+
**({"placeholder": layer.placeholder} if layer.placeholder else {}),
|
|
340
|
+
}
|
|
341
|
+
for layer in construction.layers
|
|
342
|
+
],
|
|
343
|
+
"created": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
344
|
+
}
|
|
345
|
+
path.write_text(json.dumps(payload, indent=1), encoding="utf-8")
|
|
346
|
+
return path
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def load_construction(name_or_slug: str, store_) -> Construction | None:
|
|
350
|
+
directory = constructions_dir()
|
|
351
|
+
candidates = [directory / f"{slugify(name_or_slug)}.json"]
|
|
352
|
+
for file in directory.glob("*.json"):
|
|
353
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
354
|
+
if data.get("name") == name_or_slug:
|
|
355
|
+
candidates.insert(0, file)
|
|
356
|
+
break
|
|
357
|
+
for file in candidates:
|
|
358
|
+
if file.exists():
|
|
359
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
360
|
+
layers = []
|
|
361
|
+
for entry in data.get("layers", []):
|
|
362
|
+
placeholder = entry.get("placeholder") if isinstance(entry.get("placeholder"), dict) else None
|
|
363
|
+
layers.append(
|
|
364
|
+
ConstructionLayer(
|
|
365
|
+
material_id=entry.get("material_id"),
|
|
366
|
+
thickness_m=float(entry["thickness_m"]),
|
|
367
|
+
placeholder=(
|
|
368
|
+
{
|
|
369
|
+
"name": str(placeholder.get("name") or ""),
|
|
370
|
+
"lambda_value": finite_or_none(placeholder.get("lambda_value")),
|
|
371
|
+
}
|
|
372
|
+
if placeholder
|
|
373
|
+
else None
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
return Construction(name=data["name"], design_usage=data.get("design_usage"), layers=layers)
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def list_constructions() -> list[str]:
|
|
382
|
+
names = []
|
|
383
|
+
for file in constructions_dir().glob("*.json"):
|
|
384
|
+
try:
|
|
385
|
+
names.append(json.loads(file.read_text(encoding="utf-8"))["name"])
|
|
386
|
+
except (json.JSONDecodeError, KeyError):
|
|
387
|
+
continue
|
|
388
|
+
return sorted(names)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def delete_construction(name_or_slug: str) -> bool:
|
|
392
|
+
directory = constructions_dir()
|
|
393
|
+
named = _find_stored_name_file(directory, name_or_slug)
|
|
394
|
+
file = named if named is not None else directory / f"{slugify(name_or_slug)}.json"
|
|
395
|
+
if file.exists():
|
|
396
|
+
file.unlink()
|
|
397
|
+
return True
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# NON-SPEC vendor content: some producer tools encode a layer stack inside the
|
|
403
|
+
# <construction> string body (format observed as:
|
|
404
|
+
# 001[HEADER;][PREFIX$]THICK@GUID(FLAGS);...
|
|
405
|
+
# ). This is NOT part of materialsdb103.xsd (which defines a plain string).
|
|
406
|
+
# The decoder below is best-effort and read-only: unknown tokens are preserved
|
|
407
|
+
# verbatim, unresolvable guids are flagged by the caller, never fatal.
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
_LEGACY_LAYER_RE = re.compile(r"(?:\d+:\d+\$)?([0-9.]+)@([0-9a-fA-F-]{36})(?:\(([^)]*)\))?")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def parse_legacy_stack(body: str) -> dict:
|
|
414
|
+
"""Best-effort decode of a vendor-specific construction stack string.
|
|
415
|
+
|
|
416
|
+
Returns {"version": str, "variants": [{"header_raw": str,
|
|
417
|
+
"layers": [{"guid", "thickness_m", "flags_raw"}]}], "raw": body}.
|
|
418
|
+
Semantics of header numbers / flag letters are UNKNOWN and preserved
|
|
419
|
+
verbatim."""
|
|
420
|
+
body = body.strip()
|
|
421
|
+
version = body[:3]
|
|
422
|
+
variants = []
|
|
423
|
+
for group in re.findall(r"\[([^\]]*)\]", body):
|
|
424
|
+
header_raw = ""
|
|
425
|
+
layers = []
|
|
426
|
+
for segment in (s for s in group.split(";") if s.strip()):
|
|
427
|
+
match = _LEGACY_LAYER_RE.search(segment)
|
|
428
|
+
if match is None:
|
|
429
|
+
# not a layer -> opaque header token (only valid before layers)
|
|
430
|
+
if not layers:
|
|
431
|
+
header_raw = segment
|
|
432
|
+
continue
|
|
433
|
+
layers.append(
|
|
434
|
+
{
|
|
435
|
+
"guid": match.group(2),
|
|
436
|
+
"thickness_m": float(match.group(1)),
|
|
437
|
+
"flags_raw": match.group(3) or "",
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
variants.append({"header_raw": header_raw, "layers": layers})
|
|
441
|
+
return {"version": version, "variants": variants, "raw": body}
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Entry point for the materialsdb picker web UI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
from materialsdb.gui.discovery import remove_listener_info, write_listener_info
|
|
7
|
+
from materialsdb.gui.server import make_server
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(prog="materialsdb-gui", description="Explore and export materialsdb materials.")
|
|
12
|
+
parser.add_argument("--port", type=int, default=8619, help="local port (default 8619)")
|
|
13
|
+
parser.add_argument("--no-browser", action="store_true", help="do not open a browser tab")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
server = make_server(port=args.port)
|
|
17
|
+
url = f"http://127.0.0.1:{server.server_address[1]}"
|
|
18
|
+
write_listener_info(server.server_address[1], server.gui_state.token)
|
|
19
|
+
print(f"materialsdb picker on {url} (Ctrl+C to stop)")
|
|
20
|
+
if not args.no_browser:
|
|
21
|
+
webbrowser.open(url)
|
|
22
|
+
try:
|
|
23
|
+
server.serve_forever()
|
|
24
|
+
except KeyboardInterrupt:
|
|
25
|
+
pass
|
|
26
|
+
finally:
|
|
27
|
+
remove_listener_info()
|
|
28
|
+
server.server_close()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Discovery file bridging the GUI server and local listeners (Bonsai add-on).
|
|
2
|
+
|
|
3
|
+
The GUI writes {port, token, pid} into the shared cache folder at startup
|
|
4
|
+
and removes it on clean shutdown; listeners read it to authenticate. The
|
|
5
|
+
path comes from cache.get_cache_folder() (APPDATA / XDG_CACHE_HOME aware)."""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from materialsdb import cache
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def listener_info_path() -> Path:
|
|
15
|
+
return cache.get_cache_folder() / "gui.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_listener_info(port: int, token: str) -> Path:
|
|
19
|
+
path = listener_info_path()
|
|
20
|
+
path.write_text(
|
|
21
|
+
json.dumps({"port": int(port), "token": token, "pid": os.getpid()}),
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
)
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def remove_listener_info() -> None:
|
|
28
|
+
listener_info_path().unlink(missing_ok=True)
|