raystrack 1.0.1__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.
raystrack/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ from .main import (
2
+ view_factor_matrix,
3
+ view_factor,
4
+ view_factor_to_tregenza_sky,
5
+ )
6
+ from .api import view_factor_outside_workflow
7
+ from .params import MatrixParams, SkyParams
8
+ from .utils.prepared import PreparedSolver
9
+ from .io import (
10
+ save_vf_matrix_json,
11
+ load_vf_matrix_json,
12
+ save_meshes_json,
13
+ load_meshes_json,
14
+ merge_vf_matrix
15
+ )
16
+
17
+ __all__ = [
18
+ "view_factor_matrix",
19
+ "view_factor",
20
+ "view_factor_to_tregenza_sky",
21
+ "view_factor_outside_workflow",
22
+ "MatrixParams",
23
+ "SkyParams",
24
+ "PreparedSolver",
25
+ "save_vf_matrix_json",
26
+ "load_vf_matrix_json",
27
+ "save_meshes_json",
28
+ "load_meshes_json",
29
+ "merge_vf_matrix"
30
+ ]
raystrack/api.py ADDED
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+ from typing import Dict, List, Tuple
3
+
4
+ import numpy as np
5
+
6
+ from .main import (
7
+ outside_workflow_shareable,
8
+ view_factor_matrix,
9
+ view_factor_matrix_and_sky,
10
+ view_factor_to_tregenza_sky,
11
+ )
12
+ from .params import MatrixParams, SkyParams
13
+ from .utils.helpers import (
14
+ enforce_reciprocity_and_rowsum as _enforce_reciprocity_and_rowsum,
15
+ enforce_reciprocity_only as _enforce_reciprocity_only,
16
+ )
17
+ from .utils.prepared import PreparedSolver
18
+
19
+
20
+ def _row_sum(row: Dict[str, float]) -> float:
21
+ return float(sum(float(v) for v in row.values()))
22
+
23
+
24
+ def view_factor_outside_workflow(
25
+ meshes: List[Tuple[str, np.ndarray, np.ndarray]],
26
+ *,
27
+ matrix_params: MatrixParams,
28
+ sky_params: SkyParams,
29
+ prepared: PreparedSolver | None = None,
30
+ ) -> Tuple[
31
+ Dict[str, Dict[str, float]],
32
+ Dict[str, Dict[str, float]],
33
+ Dict[str, Dict[str, float]],
34
+ ]:
35
+ """Compute scene VF matrix, sky VF and the residual fraction.
36
+
37
+ Steps
38
+ - Compute regular view-factor matrix (scene-to-scene).
39
+ - Compute Radiance-style sky view factor(s): merged (Sky) or 145 patches.
40
+ - For each emitter, compute the residual fraction required so that the
41
+ total view factor sums to one: ``1 - sum(scene VFs) - sky_total``.
42
+
43
+ Shared-ray optimization
44
+ -----------------------
45
+ When ``matrix_params`` and ``sky_params`` use the same sampling and device
46
+ configuration, this workflow traces one set of rays per emitter and derives
47
+ both outputs from that shared sample set. In that mode:
48
+
49
+ - scene hits are accumulated into the view-factor matrix
50
+ - rays that miss all geometry are classified directly into the sky result
51
+
52
+ This avoids tracing the same rays twice. When the two parameter sets are
53
+ not compatible, the workflow falls back to the legacy behaviour of calling
54
+ :func:`view_factor_matrix` and :func:`view_factor_to_tregenza_sky`
55
+ separately.
56
+
57
+ Prepared-state reuse
58
+ --------------------
59
+ Pass a shared :class:`~raystrack.utils.prepared.PreparedSolver` via
60
+ ``prepared`` when solving the same mesh set repeatedly. The workflow will
61
+ then reuse prepared triangle buffers, BVHs, ray tables and CUDA uploads
62
+ across runs.
63
+
64
+ Parameters
65
+ ----------
66
+ meshes : list of (name, V, F)
67
+ Scene meshes (float32/float64 vertices, int faces).
68
+ matrix_params, sky_params : MatrixParams
69
+ Passed through to the matrix and sky solvers. To enable the shared-ray
70
+ path, the two parameter sets must agree on the ray-generation settings
71
+ (`samples`, `rays`, `seed`) and execution settings (`bvh`, `device`,
72
+ `cuda_async`, `gpu_raygen`). The matrix solve must also use
73
+ ``flip_faces=False``.
74
+
75
+ Returns
76
+ -------
77
+ vf_scene : dict
78
+ Scene view-factor matrix. For compatible parameter sets this comes from
79
+ the shared-ray solve; otherwise it is returned by
80
+ :func:`view_factor_matrix`.
81
+ sky_vf : dict
82
+ Sky view factor(s): either {'Sky': vf} or {'Sky_Patch_i': vf} per
83
+ emitter. For compatible parameter sets this is derived from the same
84
+ rays that produced ``vf_scene``.
85
+ rest_vf : dict
86
+ Residual view factor per emitter (``{"Rest": value}``) so that
87
+ ``scene + sky + rest = 1``.
88
+ """
89
+ if not isinstance(matrix_params, MatrixParams):
90
+ raise TypeError("matrix_params must be a MatrixParams instance")
91
+ if not isinstance(sky_params, SkyParams):
92
+ raise TypeError("sky_params must be a SkyParams instance")
93
+ threshold = 1e-6
94
+ enforce_scene = bool(matrix_params.enforce_reciprocity_rowsum)
95
+ reciprocity_flag = bool(matrix_params.reciprocity)
96
+
97
+ # Ensure we don't auto-enforce rows at matrix stage
98
+ matrix_defaults = MatrixParams(**matrix_params.as_dict())
99
+ matrix_defaults.enforce_reciprocity_rowsum = False
100
+
101
+ if outside_workflow_shareable(matrix_defaults, sky_params):
102
+ vf_scene, sky_vf = view_factor_matrix_and_sky(
103
+ meshes,
104
+ matrix_params=matrix_defaults,
105
+ sky_params=sky_params,
106
+ prepared=prepared,
107
+ )
108
+ else:
109
+ vf_scene = view_factor_matrix(meshes, params=matrix_defaults, prepared=prepared)
110
+ sky_vf = view_factor_to_tregenza_sky(meshes, params=sky_params, prepared=prepared)
111
+
112
+ # Determine convergence tolerances
113
+ tol_matrix = float(matrix_params.tol)
114
+ tol_sky = float(sky_params.tol)
115
+ threshold = abs(float(threshold)) if threshold is not None else max(tol_matrix, tol_sky)
116
+
117
+ mesh_names = [name for name, _, _ in meshes]
118
+ scene_totals = {name: max(0.0, _row_sum(vf_scene.get(name, {}))) for name in mesh_names}
119
+
120
+ if enforce_scene:
121
+ row_targets = [scene_totals.get(name, 0.0) for name in mesh_names]
122
+ _enforce_reciprocity_and_rowsum(vf_scene, meshes, None, row_targets=row_targets)
123
+
124
+ rest_vf: Dict[str, Dict[str, float]] = {}
125
+
126
+ mesh_names = [name for name, _, _ in meshes]
127
+ sky_totals = {name: 0.0 for name in mesh_names}
128
+
129
+ for emitter in mesh_names:
130
+ row = vf_scene.get(emitter, {})
131
+ scene_sum = _row_sum(row)
132
+ sky_row = dict(sky_vf.get(emitter, {}))
133
+ if sky_params.discrete:
134
+ sky_total = float(sum(float(v) for v in sky_row.values()))
135
+ else:
136
+ sky_total = float(sky_row.get("Sky", 0.0))
137
+
138
+ if scene_sum + sky_total > 1.0 + threshold:
139
+ if sky_total > 0.0:
140
+ allowed_sky = max(0.0, 1.0 - scene_sum)
141
+ scale = min(1.0, allowed_sky / sky_total) if sky_total else 0.0
142
+ if sky_params.discrete:
143
+ for key, value in list(sky_row.items()):
144
+ sky_row[key] = float(value) * scale
145
+ sky_total = float(sum(float(v) for v in sky_row.values()))
146
+ else:
147
+ sky_row["Sky"] = float(sky_row.get("Sky", 0.0)) * scale
148
+ sky_total = float(sky_row.get("Sky", 0.0))
149
+ sky_vf[emitter] = sky_row
150
+ else:
151
+ sky_total = 0.0
152
+
153
+ sky_totals[emitter] = max(0.0, sky_total)
154
+
155
+ if enforce_scene:
156
+ row_targets = [max(0.0, 1.0 - sky_totals.get(name, 0.0)) for name in mesh_names]
157
+ _enforce_reciprocity_and_rowsum(vf_scene, meshes, None, row_targets=row_targets)
158
+ elif reciprocity_flag:
159
+ _enforce_reciprocity_only(vf_scene, meshes)
160
+
161
+ for emitter in mesh_names:
162
+ row = vf_scene.get(emitter, {})
163
+ scene_sum = _row_sum(row)
164
+ sky_row = dict(sky_vf.get(emitter, {}))
165
+ if sky_params.discrete:
166
+ sky_total = float(sum(float(v) for v in sky_row.values()))
167
+ else:
168
+ sky_total = float(sky_row.get("Sky", 0.0))
169
+
170
+ combined = scene_sum + sky_total
171
+ if combined > 1.0 + threshold and sky_total > 0.0:
172
+ allowed_sky = max(0.0, 1.0 - scene_sum)
173
+ if allowed_sky <= 0.0:
174
+ sky_row = {key: 0.0 for key in sky_row}
175
+ sky_total = 0.0
176
+ else:
177
+ scale = min(1.0, allowed_sky / sky_total)
178
+ if sky_params.discrete:
179
+ for key, value in list(sky_row.items()):
180
+ sky_row[key] = float(value) * scale
181
+ sky_total = float(sum(float(v) for v in sky_row.values()))
182
+ else:
183
+ sky_row["Sky"] = float(sky_row.get("Sky", 0.0)) * scale
184
+ sky_total = float(sky_row.get("Sky", 0.0))
185
+ sky_vf[emitter] = sky_row
186
+ combined = scene_sum + sky_total
187
+
188
+ residual = 1.0 - combined
189
+ if abs(residual) <= threshold:
190
+ residual = 0.0
191
+
192
+ rest_vf[emitter] = {"Rest": residual}
193
+
194
+ return vf_scene, sky_vf, rest_vf
195
+
196
+
197
+ __all__ = ["view_factor_outside_workflow"]
198
+
raystrack/io.py ADDED
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Dict, List, Tuple, Union
6
+ import numpy as np
7
+
8
+
9
+ VFDict = Dict[str, Dict[str, float]]
10
+ VFInput = Union[VFDict, List[VFDict]]
11
+ MeshTuple = Tuple[str, np.ndarray, np.ndarray]
12
+ Meshes = List[MeshTuple]
13
+
14
+
15
+ def _strip_direction_suffix(name: str) -> str:
16
+ if name.endswith("_front"):
17
+ return name[:-6]
18
+ if name.endswith("_back"):
19
+ return name[:-5]
20
+ return name
21
+
22
+
23
+ def merge_vf_matrix(vf_matrix: VFInput) -> VFDict:
24
+ """
25
+ Normalize and deep-merge input to a single dict of the form:
26
+ { sender: { receiver: value } }
27
+
28
+ Accepts either:
29
+ - a single dict
30
+ - a list of dicts
31
+
32
+ When a list is provided, all dicts are merged.
33
+ If the same `sender` key appears in multiple dicts,
34
+ their nested `receiver` mappings are merged instead of replaced.
35
+ Later entries overwrite earlier ones only for overlapping receiver keys.
36
+
37
+ Parameters
38
+ ----------
39
+ vf_matrix : dict or list of dict
40
+ Input dictionary/dictionaries to normalize and merge.
41
+
42
+ Returns
43
+ -------
44
+ VFDict
45
+ A single merged dictionary with combined receiver entries.
46
+
47
+ Raises
48
+ ------
49
+ TypeError
50
+ If vf_matrix is neither a dict nor a list of dicts.
51
+ """
52
+ if isinstance(vf_matrix, list):
53
+ flat: VFDict = {}
54
+ for d in vf_matrix:
55
+ if not isinstance(d, dict):
56
+ raise TypeError("All elements of vf_matrix list must be dicts")
57
+ for sender, receivers in d.items():
58
+ if sender not in flat:
59
+ flat[sender] = {}
60
+ flat[sender].update(receivers)
61
+ return flat
62
+
63
+ if isinstance(vf_matrix, dict):
64
+ return vf_matrix
65
+
66
+ raise TypeError("vf_matrix must be a dict or list of dicts")
67
+
68
+
69
+ def save_vf_matrix_json(vf_matrix: VFInput, save_path: str, *, strip_dir: bool = False) -> str:
70
+ """Save a view-factor matrix to a JSON file.
71
+
72
+ Receivers with value exactly 0.0 are omitted from the saved JSON to reduce
73
+ file size and noise.
74
+
75
+ When ``strip_dir=True``, receiver keys ending in ``_front`` or ``_back``
76
+ are written without that suffix and values for the same base receiver are
77
+ summed per sender row before serialization.
78
+ """
79
+ flat = merge_vf_matrix(vf_matrix)
80
+
81
+ # Basic validation of structure and numeric values
82
+ for sender, row in flat.items():
83
+ if not isinstance(sender, str):
84
+ raise TypeError("Sender keys must be strings")
85
+ if not isinstance(row, dict):
86
+ raise TypeError(f"Row for '{sender}' must be a dict mapping receiver->value")
87
+ for recv, val in row.items():
88
+ if not isinstance(recv, str):
89
+ raise TypeError("Receiver keys must be strings")
90
+ try:
91
+ float(val)
92
+ except Exception:
93
+ raise TypeError(f"Value for '{sender}'->'{recv}' must be numeric")
94
+
95
+ path = Path(save_path)
96
+ if path.suffix.lower() == "":
97
+ path = path.with_suffix(".json")
98
+ if path.parent and not path.parent.exists():
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+
101
+ # Drop zero-valued receivers
102
+ cleaned: VFDict = {}
103
+ for sender, row in flat.items():
104
+ pruned: Dict[str, float] = {}
105
+ for key, value in row.items():
106
+ value_f = float(value)
107
+ if value_f == 0.0:
108
+ continue
109
+ out_key = _strip_direction_suffix(key) if strip_dir else key
110
+ pruned[out_key] = pruned.get(out_key, 0.0) + value_f
111
+ cleaned[sender] = pruned
112
+
113
+ with path.open("w", encoding="utf-8") as fh:
114
+ json.dump(cleaned, fh, ensure_ascii=False, indent=2, sort_keys=True)
115
+
116
+ return str(path.resolve())
117
+
118
+
119
+ def load_vf_matrix_json(load_path: str) -> VFDict:
120
+ """Load a view-factor matrix JSON file and return a dictionary."""
121
+ path = Path(load_path)
122
+ if not path.exists():
123
+ raise FileNotFoundError(f"File not found: {load_path}")
124
+
125
+ with path.open("r", encoding="utf-8") as fh:
126
+ data = json.load(fh)
127
+
128
+ if not isinstance(data, dict):
129
+ raise TypeError("Loaded JSON must be a dict of dicts")
130
+
131
+ # Validate and coerce values to float
132
+ out: VFDict = {}
133
+ for sender, row in data.items():
134
+ if not isinstance(sender, str) or not isinstance(row, dict):
135
+ raise TypeError("Invalid structure: expected {str: {str: number}}")
136
+ new_row: Dict[str, float] = {}
137
+ for recv, val in row.items():
138
+ if not isinstance(recv, str):
139
+ raise TypeError("Receiver keys must be strings")
140
+ try:
141
+ new_row[recv] = float(val)
142
+ except Exception:
143
+ raise TypeError(f"Value for '{sender}'->'{recv}' must be numeric")
144
+ out[sender] = new_row
145
+
146
+ return out
147
+
148
+
149
+ # ---------------------------------------------------------------
150
+ # Mesh geometry JSON IO
151
+ # ---------------------------------------------------------------
152
+
153
+ def save_meshes_json(meshes: Meshes, save_path: str) -> str:
154
+ """Save meshes to a JSON file.
155
+
156
+ The expected input format is a list of tuples:
157
+ [(name: str, V: float32[N,3], F: int32[M,3]), ...]
158
+
159
+ The JSON structure is:
160
+ { "meshes": [
161
+ {"name": str,
162
+ "vertices": [[x,y,z], ...],
163
+ "faces": [[i,j,k], ...]
164
+ }, ...
165
+ ]}
166
+ """
167
+ if not isinstance(meshes, list):
168
+ raise TypeError("meshes must be a list of (name, V, F) tuples")
169
+
170
+ payload = {"meshes": []}
171
+ for item in meshes:
172
+ if not (isinstance(item, tuple) and len(item) == 3):
173
+ raise TypeError("Each mesh must be a (name, V, F) tuple")
174
+ name, V, F = item
175
+ if not isinstance(name, str) or name.strip() == "":
176
+ raise TypeError("Mesh name must be a non-empty string")
177
+ V = np.asarray(V, dtype=np.float32)
178
+ F = np.asarray(F, dtype=np.int32)
179
+ if V.ndim != 2 or V.shape[1] != 3:
180
+ raise ValueError(f"Vertices for '{name}' must have shape (N,3)")
181
+ if F.ndim != 2 or F.shape[1] != 3:
182
+ raise ValueError(f"Faces for '{name}' must have shape (M,3) of triangles")
183
+ payload["meshes"].append(
184
+ {
185
+ "name": name,
186
+ "vertices": V.tolist(),
187
+ "faces": F.tolist(),
188
+ }
189
+ )
190
+
191
+ path = Path(save_path)
192
+ if path.suffix.lower() == "":
193
+ path = path.with_suffix(".json")
194
+ if path.parent and not path.parent.exists():
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+
197
+ with path.open("w", encoding="utf-8") as fh:
198
+ json.dump(payload, fh, ensure_ascii=False, indent=2)
199
+
200
+ return str(path.resolve())
201
+
202
+
203
+ def load_meshes_json(load_path: str) -> Meshes:
204
+ """Load meshes from a JSON file saved by save_meshes_json.
205
+
206
+ Returns a list of (name, V, F), where V is float32[N,3], F is int32[M,3].
207
+ """
208
+ path = Path(load_path)
209
+ if not path.exists():
210
+ raise FileNotFoundError(f"File not found: {load_path}")
211
+
212
+ with path.open("r", encoding="utf-8") as fh:
213
+ data = json.load(fh)
214
+
215
+ if not isinstance(data, dict) or "meshes" not in data:
216
+ raise TypeError("Invalid mesh JSON: expected an object with 'meshes' list")
217
+ meshes_raw = data["meshes"]
218
+ if not isinstance(meshes_raw, list):
219
+ raise TypeError("'meshes' must be a list")
220
+
221
+ out: Meshes = []
222
+ for i, entry in enumerate(meshes_raw):
223
+ if not isinstance(entry, dict):
224
+ raise TypeError("Each entry in 'meshes' must be an object")
225
+ name = entry.get("name")
226
+ V = entry.get("vertices")
227
+ F = entry.get("faces")
228
+ if not isinstance(name, str) or name.strip() == "":
229
+ raise TypeError(f"Entry {i}: 'name' must be a non-empty string")
230
+ V = np.asarray(V, dtype=np.float32)
231
+ F = np.asarray(F, dtype=np.int32)
232
+ if V.ndim != 2 or V.shape[1] != 3:
233
+ raise ValueError(f"Entry {i} ('{name}'): vertices must have shape (N,3)")
234
+ if F.ndim != 2 or F.shape[1] != 3:
235
+ raise ValueError(f"Entry {i} ('{name}'): faces must have shape (M,3)")
236
+ out.append((name, V, F))
237
+
238
+ return out