OBPAnalyser 0.0.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Anton Wiberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: OBPAnalyser
3
+ Version: 0.0.1
4
+ Summary: A package for analysation of open beam files (OBP and OBF).
5
+ Author-email: Anton Wiberg <wiberg.anton@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/wiberganton/OBAnalyser
8
+ Project-URL: Repository, https://github.com/wiberganton/OBAnalyser
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: obplib
13
+ Requires-Dist: numpy
14
+ Requires-Dist: py3mf_slicer
15
+ Requires-Dist: matplotlib
16
+ Requires-Dist: scipy
17
+ Requires-Dist: pandas
18
+ Requires-Dist: pyyaml
19
+ Dynamic: license-file
20
+
21
+ # OBAnalyser
22
+ This package is used to analyse obf and obp file and make it possible to make heat build compensations
@@ -0,0 +1,2 @@
1
+ # OBAnalyser
2
+ This package is used to analyse obf and obp file and make it possible to make heat build compensations
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "OBPAnalyser"
7
+ version = "0.0.1"
8
+ authors = [{ name = "Anton Wiberg", email = "wiberg.anton@gmail.com" }]
9
+ description = "A package for analysation of open beam files (OBP and OBF)."
10
+ readme = "README.md"
11
+ requires-python = ">=3.7"
12
+ license = { text = "MIT" }
13
+ dependencies = [
14
+ "obplib",
15
+ "numpy",
16
+ "py3mf_slicer",
17
+ "matplotlib",
18
+ "scipy",
19
+ "pandas",
20
+ "pyyaml"
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/wiberganton/OBAnalyser"
25
+ Repository = "https://github.com/wiberganton/OBAnalyser"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: OBPAnalyser
3
+ Version: 0.0.1
4
+ Summary: A package for analysation of open beam files (OBP and OBF).
5
+ Author-email: Anton Wiberg <wiberg.anton@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/wiberganton/OBAnalyser
8
+ Project-URL: Repository, https://github.com/wiberganton/OBAnalyser
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: obplib
13
+ Requires-Dist: numpy
14
+ Requires-Dist: py3mf_slicer
15
+ Requires-Dist: matplotlib
16
+ Requires-Dist: scipy
17
+ Requires-Dist: pandas
18
+ Requires-Dist: pyyaml
19
+ Dynamic: license-file
20
+
21
+ # OBAnalyser
22
+ This package is used to analyse obf and obp file and make it possible to make heat build compensations
@@ -0,0 +1,26 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/__init__.py
5
+ src/config.py
6
+ src/OBPAnalyser.egg-info/PKG-INFO
7
+ src/OBPAnalyser.egg-info/SOURCES.txt
8
+ src/OBPAnalyser.egg-info/dependency_links.txt
9
+ src/OBPAnalyser.egg-info/requires.txt
10
+ src/OBPAnalyser.egg-info/top_level.txt
11
+ src/obanalyser/__init__.py
12
+ src/obanalyser/analyse_build.py
13
+ src/obanalyser/analyse_obp.py
14
+ src/obanalyser/compensate_build.py
15
+ src/obanalyser/data_classes.py
16
+ src/obanalyser/extract_obp_elements.py
17
+ src/obanalyser/get_3mf_data.py
18
+ src/obanalyser/get_build_order.py
19
+ src/obanalyser/heat_model_lumped.py
20
+ src/obanalyser/plotters/plot_csv.py
21
+ src/obanalyser/plotters/plot_temp.py
22
+ tests/test_3mf.py
23
+ tests/test_build_compensation.py
24
+ tests/test_build_input.py
25
+ tests/test_export_csv.py
26
+ tests/test_heat_model.py
@@ -0,0 +1,7 @@
1
+ obplib
2
+ numpy
3
+ py3mf_slicer
4
+ matplotlib
5
+ scipy
6
+ pandas
7
+ pyyaml
@@ -0,0 +1,3 @@
1
+ __init__
2
+ config
3
+ obanalyser
File without changes
@@ -0,0 +1,28 @@
1
+ import math
2
+
3
+ class Config:
4
+ def __init__(self):
5
+ # Parameters for build simulation
6
+ self.transition_time = 8.5 #time [s] for recoter and transition between layers
7
+ self.time_step = 50 # ref number for time step [us]
8
+ self.length_step = 500 # ref length step [um]
9
+ # Geometrical build setup
10
+ self.build_plate_diameter = 0.1 # diameter of build plate [m]
11
+ self.build_plate_thickness = 0.015 # thickness of build plate [m]
12
+ self.build_plate_density = 7990 # density of the material in the build plate [kg/m3]
13
+ # Heat parameters
14
+ self.environment_temp_wall = 100 # temp of the wall in the build chamber [C]
15
+ self.environment_temp_piston = 30 # temp of the wall in the build chamber [C]
16
+ self.radiation_emissivity = 0.8 # emissitivity constant for the radiation
17
+ self.thermal_conductivity = 16.0 # W/m·K
18
+ self.conduction_area_lumped = 0.005 # Area of the conductive material (m2)
19
+ self.conduction_length_lumped = 0.05 # Height which the conductivity happens (m)
20
+ # Parameters for build material
21
+ self.build_material_density = 7990 # density of the material in the build material (solid) [kg/m3]
22
+ self.build_material_heat_capacity = 500 # Specific Heat Capacity of the material in the build material (solid) [J/kg·K]
23
+ self.powder_material_density = 0.5 # relative density of the powder compared to bulk properties [-]
24
+ self.powder_material_heat_capacity = 1 # relative heat capacity of the powder compared to bulk properties [-]
25
+ # Parameters for heat simulation
26
+ self.beam_efficiency = 0.95 # Efficiency of the beam
27
+
28
+ config = Config()
File without changes
@@ -0,0 +1,45 @@
1
+ import json
2
+ import yaml
3
+ import os
4
+
5
+ from src.obanalyser import get_build_order
6
+ from src.obanalyser import analyse_obp
7
+ from src.obanalyser.data_classes import LayerInfo, BuildInfo
8
+
9
+ def analyse_build(build_file_path):
10
+ """
11
+ Reads an build_info file from an obf folder or yaml file with build info
12
+ Returns an analyse_data_classes.BuildInfo object with basic information about the build
13
+ """
14
+ build_sequence = get_build_order.get_layer_execution_sequence(build_file_path)
15
+ layer_info = get_build_order.get_other_layer_info(build_file_path)
16
+ layers = []
17
+
18
+ for i in range(len(layer_info)):
19
+ obp_info = analyse_obp.analyse_obp_files(build_sequence[i])
20
+ layer_info_object = LayerInfo(
21
+ layer_index = i,
22
+ layer_height = layer_info[i][1],
23
+ recoate_time = layer_info[i][0],
24
+ files = obp_info
25
+ )
26
+ layers.append(layer_info_object)
27
+
28
+ if build_file_path.endswith(".json"):
29
+ with open(build_file_path) as f:
30
+ data = json.load(f)
31
+ #print("data ", data)
32
+ start_temp = float(data["startHeat"]["targetTemperature"])
33
+ elif build_file_path.endswith((".yaml", ".yml")):
34
+ with open(build_file_path, 'r') as f:
35
+ data = yaml.safe_load(f)
36
+ start_temp = float(data["build"]["start_heat"]["target_temperature"])
37
+ else:
38
+ print("Not supported file type in build input")
39
+
40
+ build_info = BuildInfo(
41
+ start_temp = start_temp,
42
+ layers = layers
43
+ )
44
+
45
+ return build_info
@@ -0,0 +1,53 @@
1
+ import obplib as obp
2
+
3
+ from src.obanalyser.data_classes import FileStats
4
+
5
+ def analyse_obp_files(obp_files):
6
+ data = []
7
+ for file in obp_files:
8
+ data.append(analyse_obp_file(file))
9
+ return data
10
+
11
+ def analyse_obp_file(obp_file):
12
+ # input is (string with obp_path, number of repetitions)
13
+ # returns an data_classes.FileStats object
14
+ return analyse_obp_elements(obp.read_obp(obp_file[0]), nmb_repetitions=obp_file[1])
15
+
16
+ def analyse_obp_elements(obp_elements, nmb_repetitions=1):
17
+ t = 0 # s
18
+ energy = 0 # J
19
+ nmb_spots = 0 # n
20
+ line_length = 0 # um
21
+
22
+ for element in obp_elements:
23
+ if not isinstance(element, list):
24
+ element = [element]
25
+ for lst_el in element:
26
+ if isinstance(lst_el, obp.TimedPoints):
27
+ dwellTimes = fill_zeros_with_last_nonzero(lst_el.dwellTimes)
28
+ energy += lst_el.bp.power * sum(dwellTimes) / 1e9
29
+ nmb_spots += len(dwellTimes)
30
+ t += sum(dwellTimes) / 1e9
31
+ elif isinstance(lst_el, (obp.Line, obp.Curve)):
32
+ t += lst_el.get_segment_duration()
33
+ energy += lst_el.bp.power * lst_el.get_segment_duration()
34
+ line_length += lst_el.get_segment_length()
35
+
36
+ data = FileStats(
37
+ time = t,
38
+ energy = energy,
39
+ nmb_spots = nmb_spots,
40
+ line_length = line_length,
41
+ nmb_repetitions = nmb_repetitions
42
+ )
43
+ return data
44
+
45
+ def fill_zeros_with_last_nonzero(data):
46
+ # use for replacing zeroes in dwell time calculation
47
+ result = []
48
+ last_value = None
49
+ for value in data:
50
+ if value != 0:
51
+ last_value = value
52
+ result.append(last_value if last_value is not None else 0)
53
+ return result
@@ -0,0 +1,68 @@
1
+ import json
2
+ import copy
3
+ from pathlib import Path
4
+
5
+ import src.obanalyser.analyse_build as analyse_build
6
+ import src.obanalyser.analyse_obp as analyse_obp
7
+ import src.obanalyser.heat_model_lumped as heat_model_lumped
8
+ import src.obanalyser.plotters.plot_temp as plot_temp
9
+
10
+ def lumped_heat_model_compensation(build_info_path, thermal_mass, obp_path_heating, obp_path_cooling):
11
+ """
12
+ input: build_info_path is a string with path to the build info in json format
13
+ thermal mass is a list with thermal masses for each layer [mass_layer1, mass_layer2, ..]
14
+ obp_path_heating is a string with a link to the file which is used to add additional energy
15
+ obp_path_cooling is a string with a link to the file which is used to cool of the build
16
+ return: new_build a json format with compensated heat_balance
17
+ """
18
+ # Get correct paths for the new build file
19
+ base_dir = Path(build_info_path).parent
20
+ obp_path_heating_rel = str(Path(obp_path_heating).relative_to(base_dir))
21
+ obp_path_cooling_rel = str(Path(obp_path_cooling).relative_to(base_dir))
22
+
23
+ temp_tolerance = 3 # degrees C
24
+ build = analyse_build.analyse_build(build_info_path) #data_classes.BuildInfo
25
+ start_temp = build.start_temp + 273.15 #degrees Kelvin
26
+ heating_info = analyse_obp.analyse_obp_file((obp_path_heating,1)) #data_classes.FileStats
27
+ cooling_info = analyse_obp.analyse_obp_file((obp_path_cooling,1)) #data_classes.FileStats
28
+ with open(build_info_path) as f:
29
+ new_build = json.load(f)
30
+ for i, layer in enumerate(build.layers):
31
+ temp = heat_model_lumped.build_temp(build, thermal_mass, up_to_layer=i)
32
+ layer_temp = temp[-1][1]
33
+ if layer_temp < start_temp-temp_tolerance:
34
+ ii = 0
35
+ while layer_temp < start_temp-temp_tolerance:
36
+ build.layers[i].files.append(copy.deepcopy(heating_info))
37
+ temp = heat_model_lumped.build_temp(build, thermal_mass, up_to_layer=i)
38
+ layer_temp = temp[-1][1]
39
+ ii += 1
40
+ new_build = add_heat_balance(new_build, i, obp_path_heating_rel, ii)
41
+ elif layer_temp > start_temp+temp_tolerance:
42
+ ii = 0
43
+ while layer_temp > start_temp+temp_tolerance:
44
+ build.layers[i].files.append(copy.deepcopy(cooling_info))
45
+ temp = heat_model_lumped.build_temp(build, thermal_mass, up_to_layer=i)
46
+ layer_temp = temp[-1][1]
47
+ ii += 1
48
+ new_build = add_heat_balance(new_build, i, obp_path_cooling_rel, ii)
49
+ return new_build
50
+
51
+ def add_heat_balance(build, layer_index, file_path, repetitions):
52
+ # Get the layers list
53
+ layers = build["layers"]
54
+ # Ensure the layer index is valid
55
+ if layer_index >= len(layers):
56
+ raise IndexError(f"Layer index {layer_index} is out of range.")
57
+ layer = layers[layer_index]
58
+ # Create the heatBalance entry
59
+ new_entry = {
60
+ "file": file_path,
61
+ "repetitions": repetitions
62
+ }
63
+ # Add or update heatBalance
64
+ if "heatBalance" in layer:
65
+ layer["heatBalance"].append(new_entry)
66
+ else:
67
+ layer["heatBalance"] = [new_entry]
68
+ return build
@@ -0,0 +1,59 @@
1
+ from dataclasses import dataclass, asdict
2
+ from typing import List
3
+ import json
4
+
5
+ @dataclass
6
+ class FileStats:
7
+ nmb_repetitions: int #n
8
+ time: float #s
9
+ energy: float #J
10
+ nmb_spots: int #n
11
+ line_length: float #m
12
+
13
+ @dataclass
14
+ class LayerInfo:
15
+ layer_index: int
16
+ layer_height: float #mm
17
+ recoate_time: float #s
18
+ files: List[FileStats]
19
+
20
+ def get_layer_time(self):
21
+ return sum(file.time * file.nmb_repetitions for file in self.files) + self.recoate_time
22
+ def get_layer_energy(self):
23
+ return sum(file.energy * file.nmb_repetitions for file in self.files)
24
+
25
+ @dataclass
26
+ class BuildInfo:
27
+ layers: List[LayerInfo]
28
+ start_temp: float #degress C
29
+
30
+ def get_total_duration(self):
31
+ total = 0.0
32
+ for layer in self.layers:
33
+ total += layer.get_layer_time()
34
+ return total
35
+
36
+ def to_json(self, file_path: str):
37
+ """Serialize the BuildInfo object to a JSON file."""
38
+ with open(file_path, "w") as f:
39
+ json.dump(asdict(self), f, indent=4)
40
+
41
+ @staticmethod
42
+ def from_json(file_path: str) -> 'BuildInfo':
43
+ """Deserialize a JSON file into a BuildInfo object."""
44
+ with open(file_path, "r") as f:
45
+ data = json.load(f)
46
+
47
+ layers = [
48
+ LayerInfo(
49
+ layer_index=l['layer_index'],
50
+ layer_height=l['layer_height'],
51
+ recoate_time=l['recoate_time'],
52
+ files=[FileStats(**fs) for fs in l['files']]
53
+ )
54
+ for l in data['layers']
55
+ ]
56
+ return BuildInfo(
57
+ layers=layers,
58
+ start_temp=data['start_temp']
59
+ )
@@ -0,0 +1,194 @@
1
+ import obplib as obp
2
+ import numpy as np
3
+ import csv
4
+ import copy
5
+ import pandas as pd
6
+ import os
7
+
8
+ from src.config import config
9
+ from src.obanalyser.analyse_obp import fill_zeros_with_last_nonzero
10
+ import src.obanalyser.plotters.plot_csv as plot_csv
11
+
12
+ def extract_multiple_files(obp_files, out_file, t0=0, plot_info=False):
13
+ """
14
+ input: obp_files is a list with obp files on the form [[(path1_layer1, rep),(path2_layer1, rep)],[(path1_layer2, rep),(path2_layer2, rep)]]
15
+ if the path = "" a delay of rep seconds is inserted
16
+ """
17
+ element_data = []
18
+ t = t0
19
+ for layer in obp_files:
20
+ for path, rep in layer:
21
+ if path == "":
22
+ element_data.extend(get_delay_elements(rep, t0=t))
23
+ t += rep
24
+ else:
25
+ for i in range(rep):
26
+ local_data, t_local = extract_from_obp_file(path, start_time=t)
27
+ element_data.extend(local_data)
28
+ t = t_local
29
+ with open(out_file, 'w', newline='') as csvfile:
30
+ fieldnames = element_data[0].keys() # Automatically get column names from the first dict
31
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
32
+ writer.writeheader() # Write column names
33
+ writer.writerows(element_data) # Write all rows
34
+ split_csv_files(out_file)
35
+ if plot_info:
36
+ plot_csv.plot_xy_path(out_file)
37
+ plot_csv.plot_power_vs_time(out_file)
38
+
39
+
40
+ def extract_from_obp_file(obp_path, start_time = 0):
41
+ """
42
+ input: obp_path string with path to obp file
43
+ start_time is in seconds when the first element is executed
44
+ return: list with point wise obp data on the form [{x,y,spot_size,power,time}]
45
+ """
46
+ elements = obp.read_obp(obp_path)
47
+ element_data = []
48
+ t = start_time
49
+ for element in elements:
50
+ if not isinstance(element, list):
51
+ element = [element]
52
+ for lst_el in element:
53
+ if isinstance(lst_el, obp.TimedPoints):
54
+ dwellTimes = fill_zeros_with_last_nonzero(lst_el.dwellTimes)
55
+ local_t = t
56
+ for i, point in enumerate(lst_el.points):
57
+ local_t += dwellTimes[i] / 1e9
58
+ element_data.append({
59
+ "x": point.x / 1000,
60
+ "y": point.y / 1000,
61
+ "spot_size": lst_el.bp.spot_size,
62
+ "power": lst_el.bp.power,
63
+ "time": local_t
64
+ })
65
+ t += sum(dwellTimes) / 1e9
66
+ elif isinstance(lst_el, obp.Line):
67
+ line_points = calc_line_point_approximation(lst_el, t0=t)
68
+ element_data.extend(line_points)
69
+ t += lst_el.get_segment_duration()
70
+ elif isinstance(lst_el, obp.Curve):
71
+ line_points = calc_curve_point_approximation(lst_el, t0=t)
72
+ element_data.extend(line_points)
73
+ t += lst_el.get_segment_duration()
74
+ return (element_data, t)
75
+
76
+
77
+ def calc_line_point_approximation(line, t0=0):
78
+ segment_length = line.get_segment_length()
79
+ points = [] # list of spots {x[mm], y[mm], spot_size[um], power[W], time[s]}
80
+ step = 0
81
+ while step <= segment_length:
82
+ speed = line.Speed
83
+ dx = line.P2.x - line.P1.x
84
+ dy = line.P2.y - line.P1.y
85
+ if step > 0:
86
+ dx *= step / segment_length
87
+ dy *= step / segment_length
88
+ else:
89
+ dx = 0
90
+ dy = 0
91
+ points.append({
92
+ "x": (line.P1.x+dx)/1000,
93
+ "y": (line.P1.y+dy)/1000,
94
+ "spot_size": line.bp.spot_size,
95
+ "power": line.bp.power,
96
+ "time": t0+step/speed
97
+ })
98
+ step += config.length_step
99
+ return points
100
+ def calc_curve_point_approximation(line, t0=0):
101
+ segment_length = line.get_segment_length()
102
+ segment_duration = line.get_segment_duration()
103
+ points = [] # list of spots {x[mm], y[mm], spot_size[um], power[W], time[s]}
104
+ steps = np.linspace(0, 1, int(segment_length/config.length_step)+1)
105
+
106
+ P1 = np.array([line.P1.x, line.P1.y])
107
+ P2 = np.array([line.P2.x, line.P2.y])
108
+ P3 = np.array([line.P3.x, line.P3.y])
109
+ P4 = np.array([line.P4.x, line.P4.y])
110
+
111
+ for step in steps:
112
+ points.append({
113
+ "x": float(line._bez(P1, P2, P3, P4, step)[0]/1000),
114
+ "y": float(line._bez(P1, P2, P3, P4, step)[1]/1000),
115
+ "spot_size": line.bp.spot_size,
116
+ "power": line.bp.power,
117
+ "time": t0+step*segment_duration
118
+ })
119
+ return points
120
+
121
+
122
+ def get_delay_elements(t, t0=0):
123
+ elements = [
124
+ {
125
+ "x": 0,
126
+ "y": 0,
127
+ "spot_size": 1000,
128
+ "power": 0,
129
+ "time": t0
130
+ },
131
+ {
132
+ "x": 0,
133
+ "y": 0,
134
+ "spot_size": 1000,
135
+ "power": 0,
136
+ "time": t0+t
137
+ }
138
+ ]
139
+ return elements
140
+
141
+ def split_csv_files(input_csv):
142
+ def save_time_xy(input_csv, output_csv):
143
+ df = pd.read_csv(input_csv)
144
+ df[['time', 'x', 'y']].to_csv(output_csv, index=False)
145
+ print(f"Saved: {output_csv}")
146
+
147
+ def save_time_spot_size_changes(input_csv, output_csv):
148
+ df = pd.read_csv(input_csv)
149
+ records = []
150
+ # Always start with the first row
151
+ first_row = df.iloc[0]
152
+ records.append({'time': first_row['time'], 'spot_size': first_row['spot_size']})
153
+ for i in range(1, len(df)):
154
+ prev_row = df.iloc[i - 1]
155
+ curr_row = df.iloc[i]
156
+ if curr_row['spot_size'] != prev_row['spot_size']:
157
+ # Add the change point with the previous value
158
+ records.append({'time': curr_row['time'], 'spot_size': prev_row['spot_size']})
159
+ # Add the change point with the new value
160
+ records.append({'time': curr_row['time'], 'spot_size': curr_row['spot_size']})
161
+ # Add final value explicitly
162
+ last_row = df.iloc[-1]
163
+ records.append({'time': last_row['time'], 'spot_size': last_row['spot_size']})
164
+ # Save to CSV
165
+ changes_df = pd.DataFrame(records)
166
+ changes_df.to_csv(output_csv, index=False)
167
+ print(f"Saved: {output_csv}")
168
+
169
+ def save_time_power_changes(input_csv, output_csv):
170
+ df = pd.read_csv(input_csv)
171
+ records = []
172
+ # Always start with the first row
173
+ first_row = df.iloc[0]
174
+ records.append({'time': first_row['time'], 'power': first_row['power']})
175
+ for i in range(1, len(df)):
176
+ prev_row = df.iloc[i - 1]
177
+ curr_row = df.iloc[i]
178
+ if curr_row['power'] != prev_row['power']:
179
+ # Add the change point with the previous value
180
+ records.append({'time': curr_row['time'], 'power': prev_row['power']})
181
+ # Add the change point with the new value
182
+ records.append({'time': curr_row['time'], 'power': curr_row['power']})
183
+ # Add final value explicitly
184
+ last_row = df.iloc[-1]
185
+ records.append({'time': last_row['time'], 'power': last_row['power']})
186
+ # Save to CSV
187
+ changes_df = pd.DataFrame(records)
188
+ changes_df.to_csv(output_csv, index=False)
189
+ print(f"Saved: {output_csv}")
190
+
191
+ base = os.path.splitext(input_csv)[0]
192
+ save_time_xy(input_csv, base + r"_time_xy.csv")
193
+ save_time_spot_size_changes(input_csv, base + r"_time_spot_size.csv")
194
+ save_time_power_changes(input_csv, base + r"_time_power.csv")
@@ -0,0 +1,46 @@
1
+ import math
2
+
3
+ import py3mf_slicer
4
+ import py3mf_slicer.get_items
5
+
6
+ from src.config import config
7
+
8
+ def analyse_3mf(model):
9
+ layer_height = py3mf_slicer.get_items.get_layer_height(model)
10
+ numb_mesh_objects = py3mf_slicer.get_items.get_number_of_mesh_objects(model)
11
+ part_areas, total_areas = get_3mf_areas(model)
12
+ plate_radius = config.build_plate_diameter/2
13
+ plate_thickness = config.build_plate_thickness
14
+ plate_mass = plate_radius**2*math.pi*plate_thickness*config.build_plate_density
15
+ powder_area = (config.build_plate_diameter/2)**2*math.pi
16
+ density_part = config.build_material_density
17
+ density_powder = density_part*config.powder_material_density
18
+ c_part = config.build_material_heat_capacity
19
+ c_plate = c_part*config.powder_material_heat_capacity
20
+ current_mass = plate_mass*c_plate
21
+ thermal_mass = []
22
+ for area in total_areas:
23
+ layer_part_area = area/1000000
24
+ layer_powder_area = powder_area-layer_part_area
25
+ current_mass = current_mass + layer_part_area*layer_height/1000*c_part*density_part + layer_powder_area*layer_height/1000*c_part*density_powder+c_part*1.08
26
+ thermal_mass.append(current_mass)
27
+ return thermal_mass
28
+
29
+
30
+ def get_3mf_areas(model):
31
+ nmb_layers = py3mf_slicer.get_items.get_number_layers(model)
32
+ max_layer = max(nmb_layers)
33
+ part_areas = []
34
+ total_areas = []
35
+ for layer in range(max_layer):
36
+ layer_areas = []
37
+ shapely_slice = py3mf_slicer.get_items.get_shapely_slice(model, layer)
38
+ for shape in shapely_slice:
39
+ if shape is not None:
40
+ layer_areas.append(shape.area)
41
+ else:
42
+ layer_areas.append(0)
43
+ part_areas.append(layer_areas)
44
+ total_areas.append(sum(layer_areas))
45
+ return (part_areas, total_areas)
46
+
@@ -0,0 +1,141 @@
1
+ import json
2
+ import yaml
3
+ import os
4
+
5
+ from src.config import config
6
+
7
+ def get_layer_execution_sequence(path):
8
+ """
9
+ Reads an build_info file from an obf folder or yaml file with build info
10
+ Returns the sequense of which obp files should be run
11
+ """
12
+ if path.endswith(".json"):
13
+ return get_layer_execution_sequence_obf(path)
14
+ elif path.endswith((".yaml", ".yml")):
15
+ return get_layer_execution_sequence_boss(path)
16
+ else:
17
+ print("Not supported file type in build input")
18
+ return None
19
+
20
+ def get_layer_execution_sequence_obf(path):
21
+ with open(path) as f:
22
+ data = json.load(f)
23
+ # Extract the base directory from the input path
24
+ base_dir = os.path.dirname(path)
25
+ layer_defaults = data.get("layerDefaults", {})
26
+ default_spatter_safe = layer_defaults.get("spatterSafe", [])
27
+ default_jump_safe = layer_defaults.get("jumpSafe", [])
28
+ default_heat_balance = layer_defaults.get("heatBalance", [])
29
+ sequence_per_layer = []
30
+
31
+ for layer in data.get("layers", []):
32
+ layer_sequence = []
33
+
34
+ jump_safe_ops = layer.get("jumpSafe", default_jump_safe)
35
+ spatter_safe_ops = layer.get("spatterSafe", default_spatter_safe)
36
+ melt_ops = layer.get("melt", [])
37
+ heat_balance_ops = layer.get("heatBalance", default_heat_balance)
38
+
39
+ for op in jump_safe_ops:
40
+ full_path = os.path.join(base_dir, op["file"])
41
+ layer_sequence.append((full_path, op["repetitions"]))
42
+
43
+ for op in spatter_safe_ops:
44
+ full_path = os.path.join(base_dir, op["file"])
45
+ layer_sequence.append((full_path, op["repetitions"]))
46
+
47
+ for op in melt_ops:
48
+ full_path = os.path.join(base_dir, op["file"])
49
+ layer_sequence.append((full_path, op["repetitions"]))
50
+
51
+ for op in heat_balance_ops:
52
+ full_path = os.path.join(base_dir, op["file"])
53
+ layer_sequence.append((full_path, op["repetitions"]))
54
+
55
+ sequence_per_layer.append(layer_sequence)
56
+
57
+ return sequence_per_layer
58
+
59
+ def get_layer_execution_sequence_boss(yaml_path):
60
+ with open(yaml_path, 'r') as f:
61
+ data = yaml.safe_load(f)
62
+
63
+ build_data = data.get('build', {})
64
+ preheat = build_data['preheat']
65
+ postheat = build_data['postheat']
66
+ layer_sets = build_data['build']['files']
67
+ total_layers = build_data['build']['layers']
68
+
69
+ preheat_file = preheat['file']
70
+ preheat_reps = preheat['repetitions']
71
+ postheat_file = postheat['file']
72
+ postheat_reps = postheat['repetitions']
73
+
74
+ sequence_per_layer = []
75
+
76
+ for i in range(total_layers):
77
+ current_layer_set = layer_sets[i % len(layer_sets)] # repeat layer sets as needed
78
+ layer_sequence = []
79
+
80
+ # Preheat (maps to spatterSafe)
81
+ layer_sequence.append((preheat_file, preheat_reps))
82
+
83
+ # Melt files (each file in the current set, 1 repetition each)
84
+ for melt_file in current_layer_set:
85
+ layer_sequence.append((melt_file, 1))
86
+
87
+ # Postheat (maps to heatBalance)
88
+ layer_sequence.append((postheat_file, postheat_reps))
89
+
90
+ sequence_per_layer.append(layer_sequence)
91
+
92
+ return sequence_per_layer
93
+
94
+
95
+ def get_other_layer_info(path):
96
+ if path.endswith(".json"):
97
+ return get_other_layer_info_obf(path)
98
+ elif path.endswith((".yaml", ".yml")):
99
+ return get_other_layer_info_yaml(path)
100
+ else:
101
+ print("Not supported file type in build input")
102
+ return None
103
+
104
+ def get_other_layer_info_obf(path):
105
+ with open(path) as f:
106
+ data = json.load(f)
107
+ layer_defaults = data.get("layerDefaults", {}).get("layerFeed", [])
108
+ layer_data = []
109
+ for layer in data.get("layers", []):
110
+ layerfeed = layer.get("layerFeed", layer_defaults)
111
+ layer_data.append((calc_layerfeed(layerfeed), -float(layerfeed["buildPistonDistance"])))
112
+ return layer_data
113
+
114
+ def get_other_layer_info_yaml(path):
115
+ with open(path, 'r') as f:
116
+ data = yaml.safe_load(f)
117
+ layerfeed = data.get('build', {})['layerfeed']
118
+ layer_height = -float(layerfeed["build_piston_distance"])
119
+ layer_time = calc_layerfeed(layerfeed)
120
+ nmb_of_layers = int(data['build']['build']['layers'])
121
+ return [(layer_time, layer_height)]*nmb_of_layers
122
+
123
+ def calc_layerfeed(layerfeed):
124
+ feed_time = config.transition_time
125
+ advance_speed = layerfeed.get("recoater_advance_speed") or layerfeed.get("recoaterAdvanceSpeed")
126
+ retract_speed = layerfeed.get("recoater_retract_speed") or layerfeed.get("recoaterRetractSpeed")
127
+ speed = (advance_speed + retract_speed)/200
128
+ if "recoater_full_repeats" in layerfeed:
129
+ full_repeats = layerfeed["recoater_full_repeats"]
130
+ elif "recoaterFullRepeats" in layerfeed:
131
+ full_repeats = layerfeed["recoaterFullRepeats"]
132
+ else:
133
+ full_repeats = None # or a default value
134
+ if "recoater_dwell_time" in layerfeed:
135
+ dwell_time = layerfeed["recoater_dwell_time"]
136
+ elif "recoaterDwellTime" in layerfeed:
137
+ dwell_time = layerfeed["recoaterDwellTime"]
138
+ else:
139
+ dwell_time = None # or a default value
140
+ new_feed_time = (full_repeats+1)*speed*feed_time+dwell_time
141
+ return new_feed_time
@@ -0,0 +1,75 @@
1
+ import math
2
+
3
+ from src.config import config
4
+
5
+ def predict_losses(T_i, t):
6
+ """
7
+ input: T_i is the inital temp (Kelvin)
8
+ t is the duration (s)
9
+ return: loss energy (J)
10
+ """
11
+ # radiation loss
12
+ sigma = 5.67e-8 # Stefan-Boltzmann constant
13
+ epsilon = config.radiation_emissivity # example emissivity
14
+ A_surface = (config.build_plate_diameter/2)**2*math.pi #area in m²
15
+ T_env = config.environment_temp_wall + 273.15 # environment temp in K
16
+ Q_rad = epsilon * sigma * A_surface * (T_i**4 - T_env**4) * t
17
+
18
+ # conducation loss
19
+ delta_T = T_i - config.environment_temp_piston
20
+ Q_con = config.thermal_conductivity*config.conduction_area_lumped*delta_T/config.conduction_length_lumped
21
+
22
+ return Q_rad + Q_con
23
+
24
+ def layer_temp(layer_info, start_temp, thermal_masses):
25
+ """
26
+ inputs: layer_info is a data_classes.LayerInfo object
27
+ start_temp is the intial temperature in Kelvin
28
+ thermal_masses is a list of thermal masses for each layer
29
+ layer is at which layer we want to evaluate
30
+ return: List with data points [time (s), temp (K)] starting from t=0
31
+ """
32
+ layer = layer_info.layer_index
33
+ data = [[0, start_temp]] # List with data points [time (s), temp (K)]
34
+ # recoate temp
35
+ temp_change = predict_temp_change(thermal_masses[layer], -predict_losses(start_temp, layer_info.recoate_time))
36
+ temp = start_temp + temp_change
37
+ t = layer_info.recoate_time
38
+ data.append([t, temp])
39
+ # loop for files
40
+ for file in layer_info.files:
41
+ for _ in range(file.nmb_repetitions):
42
+ losses = predict_losses(temp, file.time)
43
+ energy_input = file.energy*config.beam_efficiency
44
+ temp_change = predict_temp_change(thermal_masses[layer], energy_input-losses)
45
+ temp += temp_change
46
+ t += file.time
47
+ data.append([t, temp])
48
+ return data
49
+
50
+ def predict_temp_change(layer_mass, delta_energy):
51
+ return delta_energy/layer_mass
52
+
53
+
54
+ def build_temp(build_info, thermal_masses, up_to_layer=None):
55
+ """
56
+ inputs: build_info is a data_classes.BuildInfo object
57
+ thermal_masses is a list of thermal masses for each layer
58
+ return: List with data points [time (s), temp (K)] starting from t=0
59
+ """
60
+
61
+ start_temp = build_info.start_temp + 273.15
62
+ data = [[0, start_temp]] # List with data points [time (s), temp (K)]
63
+ count = 0
64
+ for layer_info in build_info.layers:
65
+ layer_data = layer_temp(layer_info, data[-1][1], thermal_masses)
66
+ for row in layer_data:
67
+ row[0] += data[-1][0]
68
+ data.extend(layer_data[1:])
69
+ if up_to_layer is not None and count >= up_to_layer:
70
+ #print("build_info ", len(layer_info.files))
71
+ break
72
+ count += 1
73
+ return data
74
+
75
+
@@ -0,0 +1,33 @@
1
+ import pandas as pd
2
+ import matplotlib.pyplot as plt
3
+
4
+ def plot_power_vs_time(csv_path):
5
+ # Read the CSV file
6
+ df = pd.read_csv(csv_path)
7
+
8
+ # Plot power vs time
9
+ plt.figure(figsize=(8, 5))
10
+ plt.plot(df['time'], df['power'], label='Power', color='blue')
11
+ plt.xlabel('Time [s]')
12
+ plt.ylabel('Power [W]')
13
+ plt.title('Power as a Function of Time')
14
+ plt.grid(True)
15
+ plt.legend()
16
+ plt.tight_layout()
17
+ plt.show()
18
+
19
+ def plot_xy_path(csv_path):
20
+ # Read the CSV file
21
+ df = pd.read_csv(csv_path)
22
+
23
+ # Plot x vs y
24
+ plt.figure(figsize=(8, 5))
25
+ plt.plot(df['x'], df['y'], marker='o', linestyle='-', color='green', label='Path')
26
+ plt.xlabel('X Coordinate')
27
+ plt.ylabel('Y Coordinate')
28
+ plt.title('XY Coordinate Path')
29
+ plt.grid(True)
30
+ plt.axis('equal') # Ensures aspect ratio is equal for X and Y
31
+ plt.legend()
32
+ plt.tight_layout()
33
+ plt.show()
@@ -0,0 +1,17 @@
1
+
2
+ import matplotlib.pyplot as plt
3
+
4
+ def plot_lump_temp(temperatures):
5
+ """
6
+ input: temperatures as list on the form [time (s), temp (K)]
7
+ """
8
+ # Split the list into two separate lists for x and y
9
+ x = [row[0] for row in temperatures]
10
+ y = [row[1] for row in temperatures]
11
+
12
+ # Plot
13
+ plt.plot(x, y, marker='o')
14
+ plt.xlabel("time (s)")
15
+ plt.ylabel("temperature (K)")
16
+ plt.grid(True)
17
+ plt.show()
@@ -0,0 +1,24 @@
1
+ import unittest
2
+ import py3mf_slicer.load
3
+ import py3mf_slicer.slice
4
+
5
+ from src.obanalyser import get_3mf_data
6
+
7
+
8
+ class TestMain(unittest.TestCase):
9
+ def test_main(self):
10
+ geometries = [
11
+ r'tests\input\geometries\test_geometry1.stl',
12
+ r'tests\input\geometries\test_geometry2.stl',
13
+ r'tests\input\geometries\test_geometry3.stl']
14
+ model = py3mf_slicer.load.load_files(geometries)
15
+ self.assertIsNotNone(model, "Model failed to load")
16
+ # Step 2: Slice the model
17
+ sliced_model = py3mf_slicer.slice.slice_model(model, 1)
18
+ self.assertIsNotNone(sliced_model, "sliced 3mf model failed")
19
+ thermal_mass = get_3mf_data.analyse_3mf(sliced_model)
20
+ self.assertIsNotNone(thermal_mass, "obp analyse failed")
21
+ print("test_3mf works!")
22
+
23
+ if __name__ == "__main__":
24
+ unittest.main()
@@ -0,0 +1,46 @@
1
+ import unittest
2
+ import py3mf_slicer
3
+ import json
4
+
5
+ import src.obanalyser.analyse_build as analyse_build
6
+ import src.obanalyser.analyse_obp as analyse_obp
7
+ import src.obanalyser.get_3mf_data as get_3mf_data
8
+ import src.obanalyser.compensate_build as compensate_build
9
+ import src.obanalyser.heat_model_lumped as heat_model_lumped
10
+ from src.obanalyser.plotters.plot_temp import plot_lump_temp
11
+
12
+
13
+ class TestMain(unittest.TestCase):
14
+ def test_main(self):
15
+ # prepare 3mf file
16
+ geometries = [
17
+ r"tests\input\cubes_test\geometry\part1.stl",
18
+ r"tests\input\cubes_test\geometry\part2.stl",
19
+ r"tests\input\cubes_test\geometry\part3.stl",
20
+ r"tests\input\cubes_test\geometry\part4.stl"]
21
+ model = py3mf_slicer.load.load_files(geometries)
22
+ sliced_model = py3mf_slicer.slice.slice_model(model, 0.1)
23
+ thermal_mass = get_3mf_data.analyse_3mf(sliced_model)
24
+ # build info
25
+ path = r"tests/input/cubes_test/buildInfo.json"
26
+ output_path = r"tests/input/cubes_test/newbuildInfo.json"
27
+ build = analyse_build.analyse_build(path)
28
+ data = heat_model_lumped.build_temp(build, thermal_mass)
29
+ #print("not compensated data: ")
30
+ #plot_lump_temp(data)
31
+
32
+ # build compensation
33
+ obp_path_heating = r"tests/input/cubes_test/obp/heat_compensation.obp"
34
+ obp_path_cooling = r"tests/input/cubes_test/obp/PostMelt_IdleScan.obp"
35
+ new_build = compensate_build.lumped_heat_model_compensation(path, thermal_mass, obp_path_heating, obp_path_cooling)
36
+ with open(output_path, "w") as json_file:
37
+ json.dump(new_build, json_file, indent=4)
38
+ new_build_object = analyse_build.analyse_build(output_path)
39
+ data = heat_model_lumped.build_temp(new_build_object, thermal_mass)
40
+ #print("compensated data: ")
41
+ #plot_lump_temp(data)
42
+ if __name__ == "__main__":
43
+ unittest.main()
44
+
45
+
46
+
@@ -0,0 +1,17 @@
1
+ import unittest
2
+ import src.obanalyser.analyse_build as analyse_build
3
+
4
+ class TestMain(unittest.TestCase):
5
+ def test_main(self):
6
+ # build info
7
+ path = r"tests\input\test_input1\buildInfo.json"
8
+ build = analyse_build.analyse_build(path)
9
+ build.to_json(r"tests\output\build_info.json")
10
+
11
+ #plot_csv_file.plot_csv(out)
12
+ print("test_build_input works")
13
+ if __name__ == "__main__":
14
+ unittest.main()
15
+
16
+
17
+
@@ -0,0 +1,18 @@
1
+ import unittest
2
+ import csv
3
+
4
+ import src.obanalyser.extract_obp_elements as extract_obp_elements
5
+ import src.obanalyser.plotters.plot_csv as plot_csv
6
+
7
+ class TestMain(unittest.TestCase):
8
+ def test_main(self):
9
+ path1 = r"C:\Users\antwi87\Downloads\OneDrive_1_23-05-2025\TEEM02_R_Plate.obp"
10
+ path2 = r"C:\Users\antwi87\Downloads\OneDrive_1_23-05-2025\LowCurrentPreheat_PT_V_left_half.obp"
11
+ path3 = r"C:\Users\antwi87\Downloads\OneDrive_1_23-05-2025\TEEM02_L_Powder.obp"
12
+
13
+ paths = [[(path1, 1), ("", 8), (path2, 10), (path3, 1)]]
14
+ out_file = r"tests\output\test_export_csv.csv"
15
+ extract_obp_elements.extract_multiple_files(paths, out_file, t0=0, plot_info=False)
16
+
17
+ if __name__ == "__main__":
18
+ unittest.main()
@@ -0,0 +1,24 @@
1
+ import unittest
2
+ import py3mf_slicer.load
3
+ import py3mf_slicer.slice
4
+ import json
5
+
6
+ from src.obanalyser import get_3mf_data
7
+ import src.obanalyser.analyse_build as analyse_build
8
+ import src.obanalyser.heat_model_lumped as heat_model_lumped
9
+ from src.obanalyser.plotters.plot_temp import plot_lump_temp
10
+ import src.obanalyser.data_classes as data_classes
11
+
12
+
13
+ class TestMain(unittest.TestCase):
14
+ def test_main(self):
15
+ build = data_classes.BuildInfo.from_json(r"tests\input\screw_test\build_info.json")
16
+ with open(r"tests\input\screw_test\thermal_mass.json", 'r') as f:
17
+ thermal_mass = json.load(f)
18
+ # thermal model
19
+ data = heat_model_lumped.build_temp(build, thermal_mass)
20
+ #plot_lump_temp(data)
21
+ print("test_heat_model works")
22
+
23
+ if __name__ == "__main__":
24
+ unittest.main()