OBPAnalyser 0.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.
- __init__.py +0 -0
- config.py +28 -0
- obanalyser/__init__.py +0 -0
- obanalyser/analyse_build.py +45 -0
- obanalyser/analyse_obp.py +53 -0
- obanalyser/compensate_build.py +68 -0
- obanalyser/data_classes.py +59 -0
- obanalyser/extract_obp_elements.py +194 -0
- obanalyser/get_3mf_data.py +46 -0
- obanalyser/get_build_order.py +141 -0
- obanalyser/heat_model_lumped.py +75 -0
- obanalyser/plotters/plot_csv.py +33 -0
- obanalyser/plotters/plot_temp.py +17 -0
- obpanalyser-0.0.1.dist-info/METADATA +22 -0
- obpanalyser-0.0.1.dist-info/RECORD +18 -0
- obpanalyser-0.0.1.dist-info/WHEEL +5 -0
- obpanalyser-0.0.1.dist-info/licenses/LICENSE +21 -0
- obpanalyser-0.0.1.dist-info/top_level.txt +3 -0
__init__.py
ADDED
|
File without changes
|
config.py
ADDED
|
@@ -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()
|
obanalyser/__init__.py
ADDED
|
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,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,18 @@
|
|
|
1
|
+
__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
config.py,sha256=nphXp2CuRFBsNMWt5NoRBq3r1N2RBlkmi6Oik_XnC2w,1715
|
|
3
|
+
obanalyser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
obanalyser/analyse_build.py,sha256=8AAo28kCNTg0z1vhkxkJFHdydpoeE1SArs4mSch-Mn0,1582
|
|
5
|
+
obanalyser/analyse_obp.py,sha256=03BKTOV6Smr2LVRwR2pUsdVbcf0-j8_GtxHYgLKYfVE,1792
|
|
6
|
+
obanalyser/compensate_build.py,sha256=FYup3sAnnKxL9ko5Kj9g3ByBTSPq3_E5887Ubyf-aO0,3239
|
|
7
|
+
obanalyser/data_classes.py,sha256=rxIu6uzr7C2fk3Nin_O9ISqZDgvO6s1cRLYFC8Zi-I0,1710
|
|
8
|
+
obanalyser/extract_obp_elements.py,sha256=rkuZeOpw7wd466Wry8rv2y36N3u7SCC5vVflqcRaQ1w,7669
|
|
9
|
+
obanalyser/get_3mf_data.py,sha256=Va-33Stw_aH317Xf8ZKECFgfSzCnFfyAo9cEdw0MY-Q,1844
|
|
10
|
+
obanalyser/get_build_order.py,sha256=3l4G7eB1yX7TEwzluPbWjo--YOVoIUNc9xsWSB9FGxA,5273
|
|
11
|
+
obanalyser/heat_model_lumped.py,sha256=YkIFUzI2R5wampOF_slULpF3Qk6HVWGhY3syYzglcgs,2889
|
|
12
|
+
obanalyser/plotters/plot_csv.py,sha256=gg6ujPeGGXFbmugY-12fxpUXTzcIMfurjBqXB31cWUU,933
|
|
13
|
+
obanalyser/plotters/plot_temp.py,sha256=KBHXAr4Wy31MuOeG7RF3D_yi-DaGrKNF7rZTk8TLtDo,441
|
|
14
|
+
obpanalyser-0.0.1.dist-info/licenses/LICENSE,sha256=SDb8Wvzu2rN3WFNxkO43H0eLPSARinqXqOO36bZ-T9w,1090
|
|
15
|
+
obpanalyser-0.0.1.dist-info/METADATA,sha256=j38SnLKhV0IBy70VdYQsF7RnzZBA4LuY3_dC9FDdU3U,729
|
|
16
|
+
obpanalyser-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
obpanalyser-0.0.1.dist-info/top_level.txt,sha256=7wI2G38wEaSvJFNRQNODhjGwUnC1q6xq0E1x4Nf6xBQ,27
|
|
18
|
+
obpanalyser-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|