├── .gitignore ├── README.md ├── config.ini ├── helper_conversions.py ├── main.py ├── output_KiCad_dynamic_spiral.py ├── output_KiCad_square_spiral.py ├── output_svg_circle_spiral.py ├── spiral_dynamic_square.py ├── spiral_simple_circle.py ├── spiral_simple_square.py ├── study_full_comparison.py ├── study_optimal_resistance.py └── study_square_vs_circle.py /.gitignore: -------------------------------------------------------------------------------- 1 | #Output text files 2 | *.txt 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magnetorquer-Calc 2 | Calculates torque-maximizing properties of a PCB magnetorquer given specific constraints. 3 | Outputs a KiCad text file with the optimized magnetorquer. 4 | 5 | ## Usage 6 | 7 | 1. Install scipy with `pip3 install scipy` 8 | 9 | 2. Modify the magnetorquer constraints in config.ini 10 | 11 | 3. Run main.py and read the output. 12 | 13 | 4. Copy the output in KiCad_spiral.txt just before 14 | the final closing parantheses of your *.kicad_pcb file 15 | 16 | 5. Add through-vias connecting the different spiral layers in KiCad. 17 | 18 | 6. The simplest way to control your magnetorquer with a microcontroller is to add a 19 | [DRV8212DRLR](https://www.digikey.com/en/products/detail/texas-instruments/DRV8212DRLR/15286835) 20 | to the circuit board. 21 | 22 | 23 | ## Additional Files 24 | 25 | The only scripts invoked for the above operations are 26 | `main.py`, `output_KiCad_square_spiral.py` and `helper_conversions.py`. 27 | 28 | The other scripts are simply part of my research to find the optimal magnetorquer design through experimentaiton. 29 | 30 | You can learn more about my reserach process by running the `study_*.py` files. 31 | They require the following additional dependencies: matplotlib, numpy. Run `pip3 install matplotlib numpy` 32 | 33 | ## Real World Applications 34 | 35 | I maded a related [video](https://youtu.be/cGJYCe6mGR0) that briefly introduces 36 | how I created a real PCB magnetorquer prototype for Husky Satellite Lab at UW. 37 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [Configuration] 2 | 3 | #### GENERAL CONSTRAINTS ##### 4 | 5 | # Total desired resistance of the magnetorquer in ohms 6 | Resistance = 100 7 | 8 | # Number of layers the PCB will have 9 | NumberOfLayers = 4 10 | 11 | # Total radius of the magnetorquer in mm 12 | OuterRadius = 40 13 | 14 | 15 | 16 | ##### MANUFACTURER SPECIFIC SETTINGS #### 17 | 18 | # Minimum tolerance gap that must be present 19 | # from the edge of one trace to the edge of another in mm 20 | GapBetweenTraces = 0.1 21 | 22 | # Thickness of PCB copper layers in oz 23 | OuterLayerThickness = 1 24 | InnerLayerThickness = 0.5 25 | 26 | 27 | #### PHYSICAL CONSTANTS (DON'T CHANGE) #### 28 | 29 | # Copper resistivity in ohm-meters 30 | CopperResistivity = 1.77e-8 31 | 32 | # How vertically thick the trace is (in mm) per oz of thickness 33 | TraceThicknessPerOz = 0.0348 34 | 35 | -------------------------------------------------------------------------------- /helper_conversions.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from configparser import ConfigParser 3 | 4 | ''' 5 | Helper Conversion Functions 6 | ''' 7 | 8 | # Read configuration 9 | config = ConfigParser() 10 | config.read(Path(__file__).with_name('config.ini')) 11 | config = config['Configuration'] 12 | 13 | 14 | def get_ohms_per_mm(trace_width_mm: float, exterior_layer: bool) -> float: 15 | ''' 16 | Parameters: 17 | - Width of PCB trace in mm 18 | - Boolean whether the trace is on PCB exterior layer 19 | Returns: ohms per mm of trace length 20 | ''' 21 | if trace_width_mm <= 0: 22 | return float("nan") 23 | 24 | thickness_m = get_trace_thickness(exterior_layer) 25 | 26 | p = config.getfloat("CopperResistivity") 27 | 28 | ohms_per_mm = p / (thickness_m * trace_width_mm) 29 | 30 | return ohms_per_mm 31 | 32 | 33 | def get_trace_thickness(exterior_layer: bool) -> float: 34 | ''' 35 | Parameters: 36 | - Boolean whether the trace is on PCB exterior layer 37 | Returns: Thickness of trace (in meters) 38 | ''' 39 | if exterior_layer: 40 | oz_thickness = config.getfloat("OuterLayerThickness") 41 | else: 42 | oz_thickness = config.getfloat("InnerLayerThickness") 43 | 44 | m_per_oz = config.getfloat("TraceThicknessPerOz") / 1000 45 | 46 | thickness_m = oz_thickness * m_per_oz 47 | 48 | return thickness_m 49 | 50 | 51 | def int_ohms_from_ext_ohms(exterior_resistance: float) -> float: 52 | ''' 53 | Parameters: 54 | - Resistance per spiral on the exterior layer 55 | Returns: Resistance (in ohms) per inner spiral required to meet total resistance config 56 | ''' 57 | return ( 58 | (config.getfloat("Resistance") - 2 * exterior_resistance) / 59 | (config.getint("NumberOfLayers") - 2) 60 | ) 61 | 62 | 63 | def spacing_from_length(length_mm: float, resistance: float, exterior: bool) -> float: 64 | ''' 65 | Parameters: 66 | - Length of trace in mm 67 | - Desired resistance in ohms 68 | - Boolean whether the trace is on PCB exterior 69 | Returns: Total spacing (in mm) between centers of adjacent traces 70 | ''' 71 | thickness_m = get_trace_thickness(exterior) 72 | 73 | p = config.getfloat("CopperResistivity") 74 | 75 | trace_width_mm = (p * length_mm) / (thickness_m * resistance) 76 | 77 | return trace_width_mm + config.getfloat("GapBetweenTraces") 78 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from helper_conversions import * 2 | from spiral_simple_square import spiral_of_resistance 3 | from scipy import optimize 4 | import output_KiCad_square_spiral 5 | 6 | ''' 7 | Main program that outputs an optimized square magnetorquer given 8 | constraints from config.ini 9 | ''' 10 | 11 | # Read configuration 12 | config = ConfigParser() 13 | config.read(Path(__file__).with_name('config.ini')) 14 | config = config['Configuration'] 15 | 16 | 17 | def total_area_sum_from_ext_ohms(ext_ohms: float) -> float: 18 | ''' 19 | Given the ohms per exterior layer, calculates the area-sum 20 | of the magnetorquer. 21 | 22 | Parameters: 23 | ext_ohms (float): the resistance (in ohms) per exterior layer 24 | Returns: 25 | - The total area_sum given this constraint 26 | 27 | ''' 28 | 29 | 30 | int_ohms = int_ohms_from_ext_ohms(ext_ohms) 31 | int_layers = config.getint("NumberOfLayers") - 2 32 | 33 | area_sum = 2 * spiral_of_resistance(ext_ohms, True)[0] 34 | area_sum += int_layers * spiral_of_resistance(int_ohms, False)[0] 35 | return area_sum 36 | 37 | 38 | def get_optimal_front_resistance() -> float: 39 | ''' 40 | Find the balance of exterior and interior spiral resistance that 41 | maximizes area-sum. 42 | 43 | Returns: 44 | - The optimal resistance per exterior layer spiral 45 | ''' 46 | 47 | front_resistance = optimize.minimize_scalar( 48 | lambda r: -total_area_sum_from_ext_ohms(r), 49 | bounds=(0, config.getfloat("Resistance")/2), 50 | method='bounded' 51 | ).x 52 | 53 | return front_resistance 54 | 55 | 56 | def print_about_spiral(spiral, resistance): 57 | ''' 58 | Helper function to print info about a spiral 59 | ''' 60 | 61 | s = spiral 62 | print(f''' Area sum: {s[0]:.4f} m^2 63 | Inner radius: {s[1]:.4f} mm 64 | Number of coils: {s[2]:.4f} 65 | Length of trace: {s[4]:.4f} mm 66 | Resistance: {resistance:.4f} ohms 67 | ''') 68 | 69 | 70 | if __name__ == "__main__": 71 | 72 | # Collect data about the optimal spirals 73 | ext_ohms = get_optimal_front_resistance() 74 | int_ohms = int_ohms_from_ext_ohms(ext_ohms) 75 | exterior = spiral_of_resistance(ext_ohms, True) 76 | interior = spiral_of_resistance(int_ohms, False) 77 | 78 | # Print information about optimal magnetorquer 79 | total_area_sum = total_area_sum_from_ext_ohms(ext_ohms) 80 | print("Optimal properties calculated given config.ini:") 81 | print(f"Total area-sum: {total_area_sum:.4f} m^2\n") 82 | print("Properties per each of the 2 external spirals:") 83 | print_about_spiral(exterior, ext_ohms) 84 | interior_layers = config.getint("NumberOfLayers") - 2 85 | print(f"Properties per each of the {interior_layers:d} internal spirals:") 86 | print_about_spiral(interior, int_ohms) 87 | 88 | 89 | # Output the optimal spiral to KiCad_spiral.txt 90 | output_KiCad_square_spiral.save_magnetorquer( 91 | exterior[3], exterior[2], interior[3], interior[2]) 92 | -------------------------------------------------------------------------------- /output_KiCad_dynamic_spiral.py: -------------------------------------------------------------------------------- 1 | import math 2 | import numpy as np 3 | from pathlib import Path 4 | from configparser import ConfigParser 5 | 6 | 7 | ''' 8 | EXPERIMENTAL 9 | Functions that can output a KiCad file of a variable trace width square spiral 10 | ''' 11 | 12 | # Read configuration 13 | config = ConfigParser() 14 | config.read(Path(__file__).with_name('config.ini')) 15 | config = config['Configuration'] 16 | 17 | 18 | class Point: 19 | def __init__(self, x, y): 20 | self.x = x 21 | self.y = y 22 | 23 | 24 | class SpiralShape: 25 | def __init__(self): 26 | self.coils = [] 27 | self.widths = [] 28 | 29 | def add_coil(self, width, *args): 30 | self.coils.append([*args]) 31 | self.widths.append(width) 32 | 33 | def flip(self): 34 | for coil in self.coils: 35 | for point in coil: 36 | point = point[1], point[0] 37 | 38 | 39 | def get_KiCad_text(self, layer): 40 | out = "" 41 | 42 | if layer % 2 == 1: 43 | self.flip() 44 | 45 | 46 | for coil_i in range(len(self.coils)): 47 | coil = self.coils[coil_i] 48 | width = self.widths[coil_i] 49 | 50 | out += get_segment(width, get_layer_name(layer), *coil[0], *coil[1]) 51 | 52 | for point_i in range(2, len(coil)): 53 | out += get_segment(width, get_layer_name(layer), 54 | *coil[point_i-1], *coil[point_i]) 55 | 56 | out += get_segment(1, get_layer_name(layer), *self.coils[-1][-1], config.getfloat("OuterRadius"), config.getfloat("OuterRadius")) 57 | 58 | return out 59 | 60 | 61 | def get_segment(width, layer, x1, y1, x2, y2): 62 | net = 0 63 | 64 | return "(segment (start {:.4f} {:.4f}) (end {:.4f} {:.4f}) (width {:.4f}) (layer {}) (net {}))\n".format( 65 | x1, 66 | y1, 67 | x2, 68 | y2, 69 | width, layer, net) 70 | 71 | 72 | 73 | def get_layer_name(layer): 74 | if layer == 0: 75 | return 'F.Cu' 76 | elif layer == config.getint("NumberOfLayers")-1: 77 | return 'B.Cu' 78 | else: 79 | return f"In{layer}.Cu" 80 | 81 | 82 | 83 | def save_spiral(exterior_shape, interior_shape): 84 | num = config.getint("NumberOfLayers") 85 | 86 | 87 | out = exterior_shape.get_KiCad_text(0) 88 | 89 | for i in range(num-2): 90 | out += interior_shape.get_KiCad_text(i+1) 91 | 92 | out += exterior_shape.get_KiCad_text(num-1) 93 | 94 | p = Path(__file__).with_name('KiCad_spiral.txt') 95 | f = open(p, "w") 96 | f.write(out) 97 | f.close() 98 | 99 | print("Saved optimal spiral in KiCad_spiral.txt") 100 | print("Paste its entire content just before the final closing parantheses of your *.kicad_pcb file") 101 | print("Save the file, and open KiCad. Your spiral should appear in the PCB editor.") 102 | print("Be sure to add through-vias connecting the different spiral layers.") 103 | 104 | -------------------------------------------------------------------------------- /output_KiCad_square_spiral.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from configparser import ConfigParser 3 | 4 | 5 | ''' 6 | Functions that output a KiCad file of a constant trace width square magnetorquer 7 | ''' 8 | 9 | # Read configuration 10 | config = ConfigParser() 11 | config.read(Path(__file__).with_name('config.ini')) 12 | config = config['Configuration'] 13 | 14 | 15 | def get_spiral(spacing, num_of_coils, trace_width, layer) -> str: 16 | 17 | reverse = (layer % 2 == 1) 18 | 19 | outer_radius = config.getfloat("OuterRadius") 20 | 21 | out = "" 22 | for i in range(num_of_coils): 23 | radius = outer_radius - i * spacing 24 | 25 | a = (-spacing-radius, -radius) 26 | b = (radius, -radius) 27 | c = (radius, radius) 28 | d = (-radius, radius) 29 | e = (-radius, spacing-radius) 30 | 31 | out += get_segment(*a, *b, trace_width, layer, reverse) 32 | out += get_segment(*b, *c, trace_width, layer, reverse) 33 | out += get_segment(*c, *d, trace_width, layer, reverse) 34 | out += get_segment(*d, *e, trace_width, layer, reverse) 35 | 36 | return out 37 | 38 | 39 | def get_segment(x1, y1, x2, y2, width, layer, reverse) -> str: 40 | net = 0 41 | 42 | offset = config.getfloat("OuterRadius") + 20 43 | 44 | x1 += offset 45 | y1 += offset 46 | x2 += offset 47 | y2 += offset 48 | 49 | if reverse: 50 | x1, y1 = y1, x1 51 | x2, y2 = y2, x2 52 | 53 | if layer == 0: 54 | layer = 'F.Cu' 55 | elif layer == config.getint("NumberOfLayers")-1: 56 | layer = 'B.Cu' 57 | else: 58 | layer = f"In{layer}.Cu" 59 | 60 | return f"(segment (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) (width {width:.4f}) (layer {layer}) (net {net}))\n" 61 | 62 | 63 | def save_magnetorquer(exterior_spacing, exterior_num_of_coils, 64 | interior_spacing, interior_num_of_coils): 65 | ''' 66 | Saves the given spiral to "KiCad_spiral.txt". 67 | 68 | Parameters: 69 | exterior_spacing: Spacing between centers of adjacent traces (in mm) on exterior layers 70 | exterior_num_of_coils: Number of coils per exterior layer 71 | interior_spacing: Spacing between centers of adjacent traces (in mm) on interior layers 72 | interior_num_of_coils: Number of coils per interior layer 73 | ''' 74 | 75 | exterior_width = exterior_spacing - config.getfloat("GapBetweenTraces") 76 | interior_width = interior_spacing - config.getfloat("GapBetweenTraces") 77 | 78 | num_of_layers = config.getint('NumberOfLayers') 79 | 80 | out = "" 81 | 82 | out += get_spiral(exterior_spacing, 83 | exterior_num_of_coils, exterior_width, 0) 84 | for i in range(num_of_layers-2): 85 | out += get_spiral(interior_spacing, 86 | interior_num_of_coils, interior_width, i+1) 87 | 88 | out += get_spiral(exterior_spacing, exterior_num_of_coils, 89 | exterior_width, num_of_layers-1) 90 | 91 | p = Path(__file__).with_name('KiCad_spiral.txt') 92 | f = open(p, "w") 93 | f.write(out) 94 | f.close() 95 | 96 | print("Saved optimal spiral in KiCad_spiral.txt") 97 | print("Paste its entire content just before the final closing parantheses of your *.kicad_pcb file") 98 | print("Save the file, and open KiCad. Your spiral should appear in the PCB editor.") 99 | print("Be sure to add through-vias connecting the different spiral layers.") 100 | -------------------------------------------------------------------------------- /output_svg_circle_spiral.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import numpy as np 3 | import math 4 | 5 | ''' 6 | EXPERIMENTAL 7 | Functions that can output an svg of a circular spiral 8 | ''' 9 | 10 | # Stores a simple point 11 | class Point: 12 | def __init__(self, x, y): 13 | self.x = x 14 | self.y = y 15 | 16 | 17 | # Get coordinates at theta of given spiral 18 | def get_cartesian_coords(a, b, theta): 19 | x = (a-b*theta) * math.cos(theta) 20 | y = (a-b*theta)*math.sin(theta) 21 | return Point(x, y) 22 | 23 | 24 | # Get slope at theta of given spiral 25 | def get_cartesian_slope(a, b, theta): 26 | return ( 27 | (-b*math.sin(theta) + (a-b*theta)*math.cos(theta)) / 28 | (-b*math.cos(theta) + (a-b*theta)*-math.sin(theta)) 29 | ) 30 | 31 | 32 | # Finds intersection of 2 point-slope lines 33 | def find_intersection(m1, p1, m2, p2): 34 | x = (m1*p1.x - p1.y - m2*p2.x + p2.y) / (m1 - m2) 35 | y = m1*(x - p1.x) + p1.y 36 | return Point(x, y) 37 | 38 | 39 | # Draws a quadratic bezier curve 40 | def curve(handle, endpoint): 41 | return "Q {:.4f} {:.4f}, {:.4f} {:.4f}\n".format(handle.x, handle.y, endpoint.x, endpoint.y) 42 | 43 | 44 | #Draw an svg spiral and save it to the current directory 45 | def save_curve_svg(outer_radius, spacing, num_of_coils, stroke_width): 46 | a = outer_radius 47 | b = spacing / (2 * math.pi) 48 | theta = num_of_coils * 2*math.pi 49 | 50 | p = Path(__file__).with_name('spiral.svg') 51 | f = open(p, "w") 52 | f.write('''\n'''.format(outer_radius*2, -outer_radius)) 58 | 59 | f.write(''' 75 | ''') 76 | f.close() 77 | -------------------------------------------------------------------------------- /spiral_dynamic_square.py: -------------------------------------------------------------------------------- 1 | import math 2 | from scipy import optimize 3 | from pathlib import Path 4 | from configparser import ConfigParser 5 | from helper_conversions import * 6 | from output_KiCad_dynamic_spiral import SpiralShape 7 | 8 | ''' 9 | EXPERIMENTAL 10 | Functions that define a variable trace width square spiral 11 | ''' 12 | 13 | # Read configuration 14 | config = ConfigParser() 15 | config.read(Path(__file__).with_name('config.ini')) 16 | config = config['Configuration'] 17 | 18 | 19 | # Different functions that relate radius to spacing 20 | 21 | def radius_proportional(multiplier, radius): 22 | 23 | # Returns radius (mm), ohms_per_mm 24 | width = multiplier / radius**1 25 | return width 26 | 27 | 28 | def spacing_proportional_to_radius(multiplier, radius): 29 | 30 | # Returns radius (mm), ohms_per_mm 31 | width = multiplier / radius**1 - config.getfloat("GapBetweenTraces") 32 | return width 33 | 34 | 35 | def constant(multiplier, radius): 36 | return multiplier 37 | 38 | 39 | def real_radius_proportional(multiplier, radius): 40 | 41 | if radius**2 <= 2 * multiplier: 42 | width = radius 43 | else: 44 | width = radius - math.sqrt(radius**2 - 2*multiplier) 45 | 46 | return width 47 | 48 | # Actual program 49 | 50 | 51 | def spiral(trace_width_multiplier, trace_width_func, exterior, return_shape=False): 52 | 53 | # Note: Starts on the outside, and spirals inwards 54 | 55 | inner_radius = 5 56 | outer_radius = config.getfloat("OuterRadius") 57 | gap = config.getfloat("GapBetweenTraces") 58 | 59 | if return_shape: 60 | shape = SpiralShape() 61 | 62 | area_sum = 0 63 | ohms = 0 64 | coils = 0 65 | 66 | current_r = outer_radius 67 | prev_r = outer_radius 68 | 69 | current_width = trace_width_func(trace_width_multiplier, current_r) 70 | 71 | while current_r + 0.5 * current_width > inner_radius: 72 | 73 | next_r = current_r - 0.5 * current_width - gap 74 | next_width = trace_width_func(trace_width_multiplier, next_r) 75 | next_r -= 0.5 * next_width 76 | 77 | length = 6*current_r + prev_r + next_r 78 | area_sum += 0.5 * length * current_r 79 | ohms += get_ohms_per_mm(current_width, exterior) * length 80 | coils += 1 81 | 82 | # Drawing 83 | if return_shape: 84 | a = (outer_radius - prev_r, outer_radius - current_r) 85 | b = (outer_radius + current_r, outer_radius - current_r) 86 | c = (outer_radius + current_r, outer_radius + current_r) 87 | d = (outer_radius - current_r, outer_radius + current_r) 88 | e = (outer_radius - current_r, outer_radius - next_r) 89 | shape.add_coil(current_width, a, b, c, d, e) 90 | 91 | prev_r = current_r 92 | current_r = next_r 93 | current_width = next_width 94 | 95 | area_sum_m = area_sum * 1e-6 96 | 97 | if return_shape: 98 | return shape 99 | else: 100 | return area_sum_m, ohms, coils 101 | 102 | 103 | def spiral_of_resistance(ohms, exterior, trace_width_func=real_radius_proportional, return_shape=False): 104 | 105 | def func(trace_width_multiplier): 106 | return spiral(trace_width_multiplier, trace_width_func, exterior)[1] - ohms 107 | 108 | trace_width_multiplier = optimize.brentq(func, 1e-6, 1e6) 109 | return spiral(trace_width_multiplier, trace_width_func, exterior, return_shape=return_shape) 110 | -------------------------------------------------------------------------------- /spiral_simple_circle.py: -------------------------------------------------------------------------------- 1 | import math 2 | from scipy import integrate 3 | from scipy import optimize 4 | from pathlib import Path 5 | from configparser import ConfigParser 6 | import unittest 7 | from helper_conversions import * 8 | 9 | ''' 10 | EXPERIMENTAL 11 | Functions that define a constant trace width circular spiral 12 | ''' 13 | 14 | 15 | # Read configuration 16 | config = ConfigParser() 17 | config.read(Path(__file__).with_name('config.ini')) 18 | config = config['Configuration'] 19 | 20 | #### ROUND FUNCTIONS #### 21 | 22 | 23 | def length_of_round_spiral(a: float, b: float, theta: float) -> float: 24 | ''' 25 | Returns the length of a circular archimedean spiral. 26 | Proof: https://planetcalc.com/3707/ 27 | 28 | Parameters: 29 | a (float): The outer radius of the spiral 30 | b (float): The decrease in radius per 1 radian of rotation 31 | theta (float): The number of radians 32 | 33 | Returns: 34 | Length (float): The length of the spiral 35 | ''' 36 | return integrate.quad(lambda t: math.sqrt((a - b*t)**2 + b**2), 0, theta)[0] 37 | 38 | 39 | def area_sum_of_round_spiral(a: float, b: float, theta: float) -> float: 40 | ''' 41 | Returns the sum of coil areas of a circular archimedean spiral. 42 | All else constant, area-sum is proportional to magnetorquer torque. 43 | 44 | Parameters: 45 | a (float): The outer radius of the spiral 46 | b (float): The decrease in radius per 1 radian of rotation 47 | theta (float): The number of radians 48 | 49 | Returns: 50 | Area-sum (float): The area-sum of the spiral 51 | ''' 52 | return integrate.quad(lambda t: 0.5*(a - b*t)**2, 0, theta)[0] 53 | 54 | 55 | def spiral( 56 | length, spacing, outer_radius=config.getfloat('OuterRadius') 57 | ) -> tuple: 58 | ''' 59 | Returns the sum of coil areas of a circular archimedean spiral. 60 | All else constant, area-sum is proportional to magnetorquer torque. 61 | 62 | Parameters: 63 | length (float): The length of the spiral's line 64 | spacing (float): The decrease in radius per 1 turn of rotation 65 | outer_radius (float): The outer radius of the spiral 66 | 67 | Returns: 68 | num_of_coils (float): The number of coils in the spiral 69 | inner_radius (float): The inner radius of the spiral 70 | area_sum (float): The total area-sum of the spiral 71 | ''' 72 | a = outer_radius 73 | b = spacing / (2 * math.pi) 74 | 75 | if b == 0: # If b is 0 we have a perfect circle 76 | theta = length / a # Circle arc length formula 77 | else: 78 | # a/b gives theta that results in 0 inner radius 79 | max_theta = a / b 80 | 81 | # If user gives a longer length, that won't fit, so return NaN 82 | if length > length_of_round_spiral(a, b, max_theta): 83 | return math.nan, math.nan, math.nan 84 | 85 | # Use math to find theta that gives a spiral of the desired length 86 | theta = optimize.brentq( 87 | lambda t: length_of_round_spiral(a, b, t) - length, 0, max_theta) 88 | 89 | # Calculate and return other properties from theta 90 | num_of_coils = theta / (2 * math.pi) 91 | inner_radius = a - b*theta 92 | area_sum = area_sum_of_round_spiral(a, b, theta) * 1e-6 93 | 94 | return area_sum, inner_radius, num_of_coils 95 | 96 | # Returns max trace length physically possible. 97 | # Takes outer radius and a function that defines spacing 98 | def max_trace_length(resistance, outer_layer): 99 | ''' 100 | Calculates the maximum length of wire that can fit on the spiral. 101 | 102 | Uses binary search and finds highest length that doesn't receive NaN 103 | from spirals.properties_of_square_spiral(). 104 | 105 | Paramters: 106 | resistance (float - ohms): The intended resistance of the spiral. 107 | outer_layer (bool): States whether spiral is on outer layer of the PCB 108 | since that influences trace thickness 109 | 110 | Returns: 111 | max_length (float - mm): Maximum length of wire that can fit on the spiral. 112 | 113 | ''' 114 | lower = 0 115 | upper = 1e6 # TODO: Algorithmatize this hard coded value 116 | 117 | while upper - lower > upper*0.001: 118 | 119 | length_guess = (upper + lower)/2 120 | s = spacing_from_length(length_guess, resistance, outer_layer) 121 | 122 | if math.isnan(spiral(length_guess, s)[0]): 123 | upper = length_guess 124 | else: 125 | lower = length_guess 126 | 127 | max_length = lower 128 | 129 | return max_length 130 | 131 | def spiral_of_resistance(resistance, outer_layer): 132 | 133 | # Dummy function to meet requirements of `optimize.minimize_scalar` 134 | def neg_area_sum_from_length(length): 135 | s = spacing_from_length(length, resistance, outer_layer) 136 | return -spiral(length, s)[0] 137 | 138 | max_length = max_trace_length(resistance, outer_layer) 139 | # Finds length that gives maximum area-sum 140 | length = optimize.minimize_scalar( 141 | neg_area_sum_from_length, 142 | bounds=(0, max_length), 143 | method='bounded' 144 | ).x 145 | 146 | # Calculate data from the optimal length 147 | spacing = spacing_from_length(length, resistance, outer_layer) 148 | optimal = spiral(length, spacing) 149 | 150 | # Return coil spacing, number of coils, and area-sum 151 | return *optimal, spacing, length 152 | 153 | class TestRoundSpiral(unittest.TestCase): 154 | 155 | # Circle with 2 coils. 156 | def test_with_two_coils(self): 157 | result = spiral(4 * math.pi, 0, 1) 158 | self.assertAlmostEqual(result[0], 2 * math.pi * 1e-6) 159 | self.assertAlmostEqual(result[1], 1) 160 | self.assertAlmostEqual(result[2], 2) 161 | 162 | # Compare with results received from https://planetcalc.com/9063/ 163 | 164 | def test_example_1(self): 165 | result = spiral(100, 1, 10) 166 | self.assertAlmostEqual(result[0], 457.73601090254937 * 1e-6) 167 | self.assertAlmostEqual(result[1], 8.25674652261) 168 | 169 | # self-calculated. 170 | self.assertAlmostEqual(result[2], 1.74325347739317) 171 | 172 | if __name__ == "__main__": 173 | unittest.main() -------------------------------------------------------------------------------- /spiral_simple_square.py: -------------------------------------------------------------------------------- 1 | import math 2 | from pathlib import Path 3 | from configparser import ConfigParser 4 | import unittest 5 | from helper_conversions import * 6 | from scipy import optimize 7 | 8 | ''' 9 | Functions that define a constant trace width square spiral 10 | ''' 11 | 12 | # Read configuration 13 | config = ConfigParser() 14 | config.read(Path(__file__).with_name('config.ini')) 15 | config = config['Configuration'] 16 | 17 | 18 | def spiral( 19 | length, spacing, outer_radius=config.getfloat('OuterRadius') 20 | ) -> tuple: 21 | ''' 22 | Returns the sum of coil areas of a square archimedean spiral. 23 | All else constant, area-sum is proportional to magnetorquer torque. 24 | 25 | Parameters: 26 | length (float): The length of the spiral's line 27 | spacing (float): The decrease in radius per 1 turn of rotation 28 | outer_radius (float): The outer radius of the spiral 29 | 30 | Returns: 31 | num_of_coils (float): The number of coils in the spiral 32 | inner_radius (float): The inner radius of the spiral 33 | area_sum (float): The total area-sum of the spiral 34 | ''' 35 | r = outer_radius 36 | area_sum = -0.5 * spacing * r 37 | length += spacing 38 | 39 | num_of_coils = 0 40 | while length > 0: 41 | num_of_coils += 1 42 | area_sum += 4 * r ** 2 43 | length -= 8 * r 44 | r -= spacing 45 | if r < 0: 46 | return math.nan, math.nan, math.nan 47 | 48 | r += spacing 49 | 50 | area_sum -= -length * r * 0.5 51 | 52 | inner_radius = r 53 | return area_sum * 1e-6, inner_radius, num_of_coils 54 | 55 | 56 | def max_trace_length(resistance, outer_layer): 57 | ''' 58 | Calculates the maximum length of wire that can fit on the spiral. 59 | 60 | Uses binary search and finds highest length that doesn't receive NaN 61 | from spirals.properties_of_square_spiral(). 62 | 63 | Paramters: 64 | resistance (float - ohms): The intended resistance of the spiral. 65 | outer_layer (bool): States whether spiral is on outer layer of the PCB 66 | since that influences trace thickness 67 | 68 | Returns: 69 | max_length (float - mm): Maximum length of wire that can fit on the spiral. 70 | 71 | ''' 72 | lower = 0 73 | upper = 1e8 # TODO: Algorithmatize this hard coded value 74 | 75 | while upper - lower > upper*0.001: 76 | 77 | length_guess = (upper + lower)/2 78 | s = spacing_from_length(length_guess, resistance, outer_layer) 79 | 80 | if math.isnan(spiral(length_guess, s)[0]): 81 | upper = length_guess 82 | else: 83 | lower = length_guess 84 | 85 | max_length = lower 86 | 87 | return max_length 88 | 89 | 90 | def spiral_of_resistance(resistance: float, outer_layer: bool): 91 | ''' 92 | Calculates a single-layer spiral that maximizes area-sum given a specific resistance 93 | 94 | Parameters: 95 | resistance (float): the desired resistance (in ohms) of the spiral 96 | outer_layer (bool): whether the spiral is on an exterior layer 97 | 98 | Returns: 99 | num_of_coils (float): The number of coils in the spiral 100 | inner_radius (float): The inner radius of the spiral 101 | area_sum (float): The total area-sum of the spiral 102 | spacing (float): The distance (in mm) from one trace center to another 103 | length (float): The total length of the trace (in mm) 104 | 105 | ''' 106 | 107 | # Dummy function to meet requirements of `optimize.minimize_scalar` 108 | def neg_area_sum_from_length(length): 109 | s = spacing_from_length(length, resistance, outer_layer) 110 | return -spiral(length, s)[0] 111 | 112 | max_length = max_trace_length(resistance, outer_layer) 113 | # Finds length that gives maximum area-sum 114 | length = optimize.minimize_scalar( 115 | neg_area_sum_from_length, 116 | bounds=(0, max_length), 117 | method='bounded' 118 | ).x 119 | 120 | # Calculate data from the optimal length 121 | spacing = spacing_from_length(length, resistance, outer_layer) 122 | optimal = spiral(length, spacing) 123 | 124 | return *optimal, spacing, length 125 | 126 | 127 | class TestSquareSpiral(unittest.TestCase): 128 | 129 | # Square with 2 coils. 130 | def test_with_two_coils(self): 131 | result = spiral(16, 0, 1) 132 | self.assertAlmostEqual(result[0], 8 * 1e-6) 133 | self.assertAlmostEqual(result[1], 1) 134 | self.assertAlmostEqual(result[2], 2) 135 | 136 | # Draw it out to confirm! 137 | 138 | def test_example_spiral_2(self): 139 | 140 | result = spiral(4, 1, 2) 141 | self.assertAlmostEqual(result[0], 4 * 1e-6) 142 | self.assertAlmostEqual(result[1], 2) 143 | self.assertAlmostEqual(result[2], 1) 144 | 145 | 146 | if __name__ == "__main__": 147 | unittest.main() 148 | -------------------------------------------------------------------------------- /study_full_comparison.py: -------------------------------------------------------------------------------- 1 | from spiral_dynamic_square import * 2 | import matplotlib.pyplot as plt 3 | import numpy as np 4 | import spiral_dynamic_square 5 | import spiral_simple_circle 6 | import spiral_simple_square 7 | 8 | ''' 9 | EXPERIMENTAL 10 | Plots graph that compares resistance to area sums of different spiral types 11 | ''' 12 | 13 | def get_data(func, label, *args): 14 | x = ohms_list 15 | y = [func(ohms, True, *args)[0] for ohms in x] 16 | 17 | ax.plot(x, y, '-o', label=label) 18 | return ohms_list, 19 | 20 | 21 | if __name__ == "__main__": 22 | 23 | # Create the figure and the line that we will manipulate 24 | fig, ax = plt.subplots() 25 | 26 | # Draw the initial lines 27 | ohms_list = np.linspace(1, 100, 100) 28 | get_data( 29 | spiral_simple_circle.spiral_of_resistance, "Constant-trace-width circle") 30 | get_data( 31 | spiral_simple_square.spiral_of_resistance, "Constant-trace-width square") 32 | get_data( 33 | spiral_dynamic_square.spiral_of_resistance, "Variable-trace-width square") 34 | #get_data( 35 | # spiral_dynamic_square.spiral_of_resistance, "Dynamic Square Simple Radius", spiral_dynamic_square.radius_proportional) 36 | #get_data( 37 | # spiral_dynamic_square.spiral_of_resistance, "Dynamic Square Constant", spiral_dynamic_square.constant) 38 | 39 | # Add all the labels 40 | ax.set_xlabel('Ohms') 41 | ax.set_ylabel('Area Sum (m^2)') 42 | ax.legend() 43 | 44 | plt.show() 45 | -------------------------------------------------------------------------------- /study_optimal_resistance.py: -------------------------------------------------------------------------------- 1 | from spiral_simple_square import * 2 | import matplotlib.pyplot as plt 3 | import numpy as np 4 | 5 | ''' 6 | EXPERIMENTAL 7 | Plots graph that shows how magnetic moment varies with resistance 8 | given constant watts of heat generation 9 | ''' 10 | 11 | def get_moment(watts, resistance): 12 | 13 | area_sum = spiral_of_resistance(resistance, True)[0] 14 | 15 | current = math.sqrt(watts / resistance) 16 | 17 | return area_sum * current 18 | 19 | 20 | 21 | def get_data(ohms_list): 22 | return [get_moment(0.25, ohms) for ohms in ohms_list] 23 | 24 | 25 | if __name__ == "__main__": 26 | 27 | # Create the figure and the line that we will manipulate 28 | fig, ax = plt.subplots() 29 | 30 | # Draw the initial lines 31 | ohms_list = np.linspace(1, 40, 100) 32 | data = get_data(ohms_list) 33 | 34 | line, = ax.plot(ohms_list, data, '-o', label="trace_proporitional") 35 | 36 | ax.set_title("assumes 0.25 watt power for 1 magnetorquer layer") 37 | 38 | 39 | plt.xlim([0, 1.1*max(ohms_list)]) 40 | plt.ylim([0, 1.1*max(data)]) 41 | 42 | # Add all the labels 43 | ax.set_xlabel('Ohms') 44 | ax.set_ylabel('Magnetic Moment') 45 | ax.legend() 46 | 47 | plt.show() -------------------------------------------------------------------------------- /study_square_vs_circle.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import matplotlib.pyplot as plt 3 | from matplotlib.widgets import Slider 4 | import spiral_simple_circle 5 | import spiral_simple_square 6 | 7 | ''' 8 | EXPERIMENTAL 9 | Plots graph that compares square to circular magnetorquers 10 | ''' 11 | 12 | 13 | def get_data(trace_width): 14 | ''' 15 | Queries other functions to get graph data of spirals. 16 | 17 | Parameters: 18 | spacing (float): The decrease in radius per revolution 19 | 20 | Returns: 21 | max_l, lengths, areas_square, areas_circle 22 | ''' 23 | if trace_width == 0: 24 | max_l = 1000 25 | else: 26 | max_l = 450/trace_width 27 | 28 | lengths = np.linspace(0, max_l, 50) 29 | areas_square = [ 30 | spiral_simple_square.spiral(l, trace_width, OUTER_RADIUS)[0] 31 | for l in lengths 32 | ] 33 | areas_circle = [ 34 | spiral_simple_circle.spiral(l, trace_width, OUTER_RADIUS)[0] 35 | for l in lengths 36 | ] 37 | return max_l, lengths, areas_square, areas_circle 38 | 39 | 40 | if __name__ == "__main__": 41 | 42 | # Constants 43 | INIT_SPACING = 0.5 # Initial slider position 44 | OUTER_RADIUS = 10 # Outer radius simulated 45 | 46 | # Create the figure and the line that we will manipulate 47 | fig, ax = plt.subplots() 48 | 49 | # Draw the initial lines 50 | _, lengths, areas_square, areas_circle = get_data(INIT_SPACING) 51 | line_square, = ax.plot(lengths, areas_square, '-s', label='Square spiral') 52 | line_circle, = ax.plot(lengths, areas_circle, '-o', 53 | label="Circular spiral") 54 | 55 | # Add all the labels 56 | ax.set_title("radius is set to 10 units") 57 | ax.set_xlabel('Length') 58 | ax.set_ylabel('Area-sum') 59 | ax.legend() 60 | 61 | # Adjust the main plot to make room for the sliders 62 | fig.subplots_adjust(bottom=0.25) 63 | 64 | # Make a horizontal slider to control the spacing. 65 | ax_spacing = fig.add_axes([0.25, 0.1, 0.6, 0.04]) 66 | width_slider = Slider( 67 | ax=ax_spacing, 68 | label='Spacing', 69 | valmin=0, 70 | valmax=1, 71 | valinit=INIT_SPACING, 72 | ) 73 | 74 | # The function to be called anytime a slider's value changes 75 | 76 | def update(_): 77 | 78 | # Collect updated data 79 | max_l, lengths, areas_square, areas_circle = get_data(width_slider.val) 80 | 81 | # Redraw the lines with the new data 82 | line_square.set_data(lengths, areas_square) 83 | line_circle.set_data(lengths, areas_circle) 84 | 85 | # Rescale the graph to fit the data 86 | max_y = max(areas_square) 87 | ax.set_xlim(-0.05*max_l, max_l) 88 | ax.set_ylim(-0.05*max_y, 1.05*max_y) 89 | 90 | fig.canvas.draw_idle() 91 | 92 | width_slider.on_changed(update) 93 | 94 | plt.show() 95 | --------------------------------------------------------------------------------