4 Commits

Author SHA1 Message Date
e6ce15aee9 Formatting 2026-01-15 18:36:56 +01:00
ac73cfcf5e Some general 2026-01-15 16:16:35 +01:00
2d067013b4 Refactor all and add some tests for the calculations 2026-01-15 16:12:08 +01:00
cc3371b73b Some refactor and tried to figure out the angles right 2026-01-15 15:25:06 +01:00
18 changed files with 229 additions and 295 deletions

2
.gitignore vendored
View File

@@ -1 +1 @@
__pycache__/ __pycache__

View File

@@ -8,12 +8,6 @@ Use adafruit servokit to manage the servos through the external board.
Enable the I2C module for the rasberry pi. Enable the I2C module for the rasberry pi.
Install the package and initialize with Install the package and initialize with
```python
from adafruit_servokit import ServoKit
kit = ServoKit(channels=16)
kit.servo[0].angle = 180
```
```text ```text
https://pinout.xyz/ Full pinout for the rpi3 https://pinout.xyz/ Full pinout for the rpi3
https://components101.com/sites/default/files/component_datasheet/SG90%20Servo%20Motor%20Datasheet.pdf Datasheet for the servomotor used https://components101.com/sites/default/files/component_datasheet/SG90%20Servo%20Motor%20Datasheet.pdf Datasheet for the servomotor used
@@ -23,7 +17,7 @@ https://ben.akrin.com/raspberry-pi-servo-jitter/ Blog post how to fix jittering
Local address Local address
inet6 fe80::7e2c:ada5:5de7:9a2c/64 inet6 fe80::7e2c:ada5:5de7:9a2c/64
## Cables ## Cables on the rpi
From right to left From right to left

View File

@@ -5,6 +5,7 @@ unit_x = np.array([1, 0, 0])
unit_y = np.array([0, 1, 0]) unit_y = np.array([0, 1, 0])
unit_z = np.array([0, 0, 1]) unit_z = np.array([0, 0, 1])
def get_axis(axis): def get_axis(axis):
"Axis are numbered from 1 to 3 from x to z." "Axis are numbered from 1 to 3 from x to z."
match axis: match axis:
@@ -18,20 +19,16 @@ def get_axis(axis):
ax = unit_x ax = unit_x
return ax return ax
def proj(vec, axis: int =1):
def proj(vec, axis: int = 1):
"""Simple vector projection onto an axis.""" """Simple vector projection onto an axis."""
ax = get_axis(axis) ax = get_axis(axis)
return np.dot(vec, ax) * ax return np.dot(vec, ax) * ax
def abs_custom(vec):
l = 0
for i in range(3):
l += vec[i] ** 2
return np.sqrt(l)
def rotate(v, angle=90, axis=1): def rotate(v, angle=90, axis=1):
"Rotate a vector with an angle around a axis with the right hand rule." "Rotate a vector with an angle around a axis with the right hand rule."
angle = angle/180 * np.pi angle = angle / 180 * np.pi
k = get_axis(axis) k = get_axis(axis)
return ( return (
@@ -40,18 +37,20 @@ def rotate(v, angle=90, axis=1):
+ k * np.dot(k, v) * (1 - np.cos(angle)) + k * np.dot(k, v) * (1 - np.cos(angle))
) )
def agl(a, b): def agl(a, b):
"Get the angle between two vectors. This is always between 0 and 180 degree." "Get the angle between two vectors. This is always between 0 and 180 degree."
return np.round(np.acos(np.dot(a, b)/(abs_custom(a) * abs_custom(b)))/(2 * np.pi) * 360) return np.round(
np.acos(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
/ (2 * np.pi)
* 360
)
def normalize(vec):
l = abs_custom(vec)
return vec/l
def get_angles(source, target): def get_angles(source, target):
"""Main function to get the phi and theta angles for a source and a target vector. Both vectors must lie on the front half sphere. """Main function to get the phi and theta angles for a source and a target vector. Both vectors must lie on the front half sphere.
Phi is from 0 to 180 where 0 means left when you look at the mirrors. The hardware is bounded between 45 and 135 degree. Thus the here provided angle needs to be subtracted by 45 and then doubled. Phi is from 0 to 180 where 0 means left when you look at the mirrors. The hardware is bounded between 45 and 135 degree. Thus the here provided angle needs to be subtracted by 45 and then doubled.
Theta is from 0 to 90 where 0 means up.""" Theta is from 0 to 90 where 0 means up."""
source_planar = source - proj(source, 3) source_planar = source - proj(source, 3)
target_planar = target - proj(target, 3) target_planar = target - proj(target, 3)
@@ -69,39 +68,15 @@ def get_angles(source, target):
if source_phi < target_phi: if source_phi < target_phi:
rota = rotate(source_planar, phi_diff, 3) rota = rotate(source_planar, phi_diff, 3)
theta_diff = agl(rota, target) theta_diff = agl(rota, target)
phi = source_phi + phi_diff/2 phi = source_phi + phi_diff / 2
else: else:
rota = rotate(target_planar, phi_diff, 3) rota = rotate(target_planar, phi_diff, 3)
theta_diff = agl(rota, source) theta_diff = agl(rota, source)
phi = target_phi + phi_diff/2 phi = target_phi + phi_diff / 2
if source_theta < target_theta: if source_theta < target_theta:
theta = target_theta + theta_diff/2 theta = target_theta + theta_diff / 2
else: else:
theta = source_theta + theta_diff/2 theta = source_theta + theta_diff / 2
return (phi, theta) return (phi, theta)
GRID_SIZE = 10
# Aufbau der Koordinaten
# Das Zentrum des Spiegels hinten rechts bildet den Ursprung
# Dann geht die x-Achse nach links und die y-Achse nach vorne
# X, Y, Z
source_orig = np.array([0, 20, 0])
target_orig = np.array([0, 20, 0])
# Strategie des Programms
# 1. Iteration ueber jeden Spiegel
# 2. Berechnung des Quellvektors und des Targetvektors fuer die Position des Spiegels
# 3. Berechne
for x in range(4):
for y in range(2):
x_size = x * GRID_SIZE
y_size = y * GRID_SIZE
phi, theta = get_angles(source_orig - unit_x * x_size - unit_y * y_size, target_orig - unit_x * x_size - unit_y * y_size)
print(f"For grid ({x}, {y}), phi = {phi} and theta = {theta}.")

9
helpers.py Normal file
View File

@@ -0,0 +1,9 @@
from objects.world import World
def print_status(world: World):
for i, mirror in enumerate(world.mirrors):
phi, theta = mirror.get_angles()
print(
f"Mirror {i} ({mirror.cluster_x}, {mirror.cluster_y}) angles -> phi: {phi:.2f}°, theta: {theta:.2f}°"
)

View File

@@ -1,5 +1,9 @@
default: sim
sim: sim:
@echo "Starting the simulation!"
uv run python simulation.py uv run python simulation.py
sync: test:
rsync -r --exclude=venv ~/solarmotor guest@hahn1.one: @echo "Try to run all the tests."
uv run python -m unittest discover -v

View File

@@ -1,6 +0,0 @@
def main():
print("Hello from solarmotor!")
if __name__ == "__main__":
main()

View File

@@ -3,9 +3,13 @@ from adafruit_servokit import ServoKit
class Board: class Board:
MIN = 500 MIN = 500
MAX = 2500 MAX = 2500
COVER = 180
count = 0
def __init__(self, channels=16, frequency=50): def __init__(self, channels=16, frequency=50):
self.channels = channels self.channels = channels
self.address = "" # For the future
self.frequency = frequency self.frequency = frequency
self.kit = ServoKit(channels=channels, frequency=frequency) self.kit = ServoKit(channels=channels, frequency=frequency)

25
objects/generic.py Normal file
View File

@@ -0,0 +1,25 @@
import numpy as np
class MovingEntity:
"""Embedded entity in the world with a position."""
def __init__(self, world):
self.world = world
self.pos = np.array((0.0, 0.0, 0.0)) # (x, y, z) in local untilted coordinates
def get_pos_rotated(self):
"""Return position rotated by world's tilt around y-axis."""
return self.world.rotate_point_y(self.pos)
def move(self, dx=0, dy=0, dz=0):
self.pos = (self.pos[0] + dx, self.pos[1] + dy, self.pos[2] + dz)
class Target(MovingEntity):
def __init__(self, world, pos=(0.0, 0.0, 0.0)):
super().__init__(world)
self.pos = np.array(pos) # Store everything in numpy
class Source(MovingEntity):
def __init__(self, world, pos=(10.0, 10.0, 10.0)):
super().__init__(world)
self.pos = np.array(pos)

48
objects/mirror.py Normal file
View File

@@ -0,0 +1,48 @@
"""
When theta motor is at 0 then mirror is up and when at 180 then mirror is front.
Phi 0 equals right and phi 180 equals left by 45 degrees.
"""
import numpy as np
from objects.generic import Source, Target
from objects.motor import Motor
from calculator import get_angles
class Mirror:
def __init__(self, world, cluster_x=0, cluster_y=0):
self.world = world # TODO: Fix this cyclic reference
self.cluster_x = cluster_x
self.cluster_y = cluster_y
# Store the motors
# Need to get first the theta because
# of the ordeing of the cables on the board
self.motor_theta: Motor = Motor(self.world.board)
self.motor_phi: Motor = Motor(self.world.board)
# Position in un-tilted coordinate system
self.pos = np.array(
[cluster_x * self.world.grid_size, cluster_y * self.world.grid_size, 0.0]
)
def get_pos_rotated(self):
return self.world.rotate_point_y(self.pos)
def set_angle_from_source_target(self, source: Source, target: Target):
"Set the angles of a mirror from global source and target vectors."
rot_pos = self.get_pos_rotated()
rel_source = source.pos - rot_pos
rel_target = target.pos - rot_pos
phi, theta = get_angles(rel_source, rel_target)
# Update the angles based on the normals in rotated positions
self.motor_phi.set_angle(phi)
self.motor_theta.set_angle(theta)
def get_angles(self):
return self.motor_phi.angle, self.motor_theta.angle

View File

@@ -1,29 +1,21 @@
"""Helpers for building moving mirrors.""" """Helpers for building moving mirrors."""
from objects.board import Board from objects.board import Board
import time
class Motor: class Motor:
"""Model a type of servo motor.""" """Model a type of servo motor."""
# Default vaules for every motor
MAX_PULSE = 2500
MIN_PULSE = 500
COVERAGE = 180 # Total degree of freedom in degrees
OFFSET = 0 # In degrees a constant to be added OFFSET = 0 # In degrees a constant to be added
SCALE = 1 # Scaling SCALE = 1 # Scaling
# Used for ids
count = 0
def __init__(self, board: Board, angle=0): def __init__(self, board: Board, angle=0):
self.board: Board = board self.board: Board = board
self.id: int = Motor.count self.id: int = Board.count
Motor.count += 1 Board.count += 1
self.angle = angle self.angle = angle
self.offset = Motor.OFFSET # Fine grained controls over every motor self.offset = Motor.OFFSET # Fine grained controls over every motor
self.coverage = Motor.COVERAGE self.coverage = Board.COVER
self.scale = Motor.SCALE self.scale = Motor.SCALE
# Initialization # Initialization
@@ -33,11 +25,6 @@ class Motor:
def set(self): def set(self):
self.board.kit.servo[self.id].angle = self.angle * self.scale + self.offset self.board.kit.servo[self.id].angle = self.angle * self.scale + self.offset
def safe_set_angle(angle=0, sleep=0.01, offset=1):
self.board.kit.servo[NUM].angle = angle + offset
time.sleep(sleep)
kit.servo[NUM].angle = angle
def set_angle(self, angle): def set_angle(self, angle):
self.angle = min(self.coverage, max(0, angle)) # Double check bad self.angle = min(self.coverage, max(0, angle)) # Double check bad
self.set() self.set()
@@ -47,5 +34,5 @@ class Motor:
def inc(self, inc): def inc(self, inc):
self.angle += inc self.angle += inc
self.angle = min(max(self.angle, 0), Motor.COVERAGE) # Clip self.angle = min(max(self.angle, 0), Board.COVER) # Clip
self.set() self.set()

View File

@@ -1,170 +0,0 @@
"""Alle gemessenen Koordinaten der Quelle und der Sonne haben den Ursprung in der linken unteren Ecke des Clusters in einem rechtshaendigen flachen System.
"""
import math
import objects.motor as motor
import numpy as np
# Einheitsvektoren
unit_x = np.array([1, 0, 0])
unit_y = np.array([0, 1, 0])
unit_z = np.array([0, 0, 1])
def get_axis(axis):
"Axis are numbered from 1 to 3 from x to z."
match axis:
case 1:
ax = unit_x
case 2:
ax = unit_y
case 3:
ax = unit_z
case _:
ax = unit_x
return ax
def proj(vec, axis: int =1):
"""Simple vector projection onto an axis."""
ax = get_axis(axis)
return np.dot(vec, ax) * ax
def abs_custom(vec):
l = 0
for i in range(3):
l += vec[i] ** 2
return np.sqrt(l)
def rotate(v, angle=90, axis=1):
"Rotate a vector with an angle around a axis with the right hand rule."
angle = angle/180 * np.pi
k = get_axis(axis)
return (
v * np.cos(angle)
+ np.cross(k, v) * np.sin(angle)
+ k * np.dot(k, v) * (1 - np.cos(angle))
)
def agl(a, b):
"Get the angle between two vectors. This is always between 0 and 180 degree."
return np.round(np.acos(np.dot(a, b)/(abs_custom(a) * abs_custom(b)))/(2 * np.pi) * 360)
def normalize(vec):
l = abs_custom(vec)
return vec/l
def get_angles(source, target):
"""Main function to get the phi and theta angles for a source and a target vector. Both vectors must lie on the front half sphere.
Phi is from 0 to 180 where 0 means left when you look at the mirrors. The hardware is bounded between 45 and 135 degree. Thus the here provided angle needs to be subtracted by 45 and then doubled.
Theta is from 0 to 90 where 0 means up."""
source_planar = source - proj(source, 3)
target_planar = target - proj(target, 3)
source_phi = agl(source_planar, unit_x)
target_phi = agl(target_planar, unit_x)
source_theta = agl(rotate(source, 90 - source_phi, 3), unit_z)
target_theta = agl(rotate(target, 90 - target_phi, 3), unit_z)
phi = None
theta = None
theta_diff = None
phi_diff = agl(source_planar, target_planar)
if source_phi < target_phi:
rota = rotate(source_planar, phi_diff, 3)
theta_diff = agl(rota, target)
phi = source_phi + phi_diff/2
else:
rota = rotate(target_planar, phi_diff, 3)
theta_diff = agl(rota, source)
phi = target_phi + phi_diff/2
if source_theta < target_theta:
theta = target_theta + theta_diff/2
else:
theta = source_theta + theta_diff/2
return (phi, theta)
class MovingEntity:
"""Embedded entity in the world with a position."""
def __init__(self, world):
self.world = world
self.pos = (0.0, 0.0, 0.0) # (x, y, z) in local untilted coordinates
def get_pos_rotated(self):
"""Return position rotated by world's tilt around y-axis."""
return self.world.rotate_point_y(self.pos)
def move(self, dx=0, dy=0, dz=0):
self.pos = (self.pos[0] + dx, self.pos[1] + dy, self.pos[2] + dz)
class Target(MovingEntity):
def __init__(self, world, pos=(0.0, 0.0, 0.0)):
super().__init__(world)
self.pos = pos
class Source(MovingEntity):
def __init__(self, world, pos=(10.0, 10.0, 10.0)):
super().__init__(world)
self.pos = pos
class Mirror:
def __init__(self, world, cluster_x=0, cluster_y=0):
self.world = world
self.cluster_x = cluster_x
self.cluster_y = cluster_y
# Store the motors
self.phi = motor.Motor(self.world.board)
self.theta = motor.Motor(self.world.board)
# Position in un-tilted coordinate system
self.pos = (cluster_x * self.world.grid_size, cluster_y * self.world.grid_size, 0.0)
def get_pos_rotated(self):
return self.world.rotate_point_y(self.pos)
def set_angle_from_source_target(self, source: Source, target: Target):
"Set the angles of a mirror from global source and target vectors."
rot_pos = np.array([self.get_pos_rotated()])
rel_source = np.array([source.pos]) - rot_pos
rel_target = np.array([target.pos]) - rot_pos
phi, theta = get_angles(rel_source, rel_target)
# Update the angles based on the normals in rotated positions
self.phi.set_angle(phi)
self.theta.set_angle(theta)
def get_angles(self):
return self.phi.angle, self.theta.angle
class World:
def __init__(self, board, tilt_deg=0.0):
self.board = board
self.grid_size = 10 # In cm
self.tilt_deg = tilt_deg # Tilt of the grid system around y-axis
self.mirrors = []
def add_mirror(self, mirror):
self.mirrors.append(mirror)
def update_mirrors_from_source_target(self, source: Source, target: Target):
for mirror in self.mirrors:
mirror.set_angle_from_source_target(source, target)
def rotate_point_y(self, point):
"""Rotate a point around the y-axis by the world's tilt angle."""
x, y, z = point
theta = math.radians(self.tilt_deg)
cos_t = math.cos(theta)
sin_t = math.sin(theta)
x_rot = x * cos_t + z * sin_t
y_rot = y
z_rot = -x * sin_t + z * cos_t
return (x_rot, y_rot, z_rot)

56
objects/world.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Alle gemessenen Koordinaten der Quelle und der Sonne haben den Ursprung in der rechten
oberen Ecke des Clusters in einem rechtshaendigen flachen System.
Achsen in der Welt mit der z-Achse nach oben.
Alles in cm gemessen.
Der phi Winkel wird zur x-Achse gemessen.
Der thetha Winkel wird zur z-Achse gemessen.
So sind y und z Koordinaten immer positiv.
x (2,0) (1,0) (0,0)
<-----S----S----S O:z
|
S S S (0,1)
|
v y
"""
from objects.generic import Source, Target
from objects.mirror import Mirror
import numpy as np
import math
class World:
def __init__(self, board, tilt_deg=0.0):
self.board = board
self.grid_size = 10 # In cm
self.tilt_deg = tilt_deg # Tilt of the grid system around y-axis
self.mirrors: list[Mirror] = []
def add_mirror(self, mirror):
self.mirrors.append(mirror)
def update_mirrors_from_source_target(self, source: Source, target: Target):
for mirror in self.mirrors:
mirror.set_angle_from_source_target(source, target)
def rotate_point_y(self, point):
"""Rotate a point around the y-axis by the world's tilt angle."""
x, y, z = point
theta = math.radians(self.tilt_deg)
cos_t = np.cos(theta)
sin_t = np.sin(theta)
x_rot = x * cos_t + z * sin_t
y_rot = y
z_rot = -x * sin_t + z * cos_t
return np.array([x_rot, y_rot, z_rot])

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "solarmotor" name = "solarmotor"
version = "0.1.0" version = "0.1.1"
description = "Add your description here" description = "Binary to simulate mirrors"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [

View File

@@ -1,13 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
# Simple python shell for all packages
pkgs.mkShell {
buildInputs = with pkgs; [
(pkgs.python313.withPackages (ps: with ps; [
matplotlib
numpy
ty
]))
];
}

View File

@@ -1,56 +1,39 @@
from helpers import print_status
import time import time
import math
# Solar module for simulation of world from objects.generic import Source, Target
import objects.solar as solar # Modeling of the world from objects.world import World
from objects.mirror import Mirror
from objects.motor import Motor # Small helper functions and constants
from objects.board import Board from objects.board import Board
STEP = 10 # Solar module for simulation of world
LOOP_DELAY = 0.005 # In seconds LOOP_DELAY = 0.005 # In seconds
# Testing embedding the mirrors in the world # Testing embedding the mirrors in the world
board = Board() board = Board()
world = solar.World(board, tilt_deg=0) world = World(board, tilt_deg=0)
HEIGHT = 30 source = Source(world, pos=(0, 50, 0))
target = Target(world, pos=(0, 50, 0))
source = solar.Source(world, pos=(0, 50, 0)) # Create mirrors in a grid
target = solar.Target(world, pos=(0, 50, 0)) for x in range(2):
for y in range(1):
# Create mirrors in a 3x2 grid mirror = Mirror(world, cluster_x=x, cluster_y=y)
for x in range(4):
for y in range(2):
mirror = solar.Mirror(world, cluster_x=x, cluster_y=y)
world.add_mirror(mirror) world.add_mirror(mirror)
world.update_mirrors_from_source_target(source, target)
def print_status():
for i, mirror in enumerate(world.mirrors):
pitch, yaw = mirror.get_angles()
print(f"Mirror {i} ({mirror.cluster_x}, {mirror.cluster_y}) angles -> pitch: {pitch:.2f}°, yaw: {yaw:.2f}°")
print_status()
a = 1
t = time.time()
# Main # Main
try: try:
while True: while True:
source.move(0, 0, 0.1) #source.move(0, 0, 0.5)
#source.move(10 * math.sin(a * t), 10 * math.cos(a * t)) #source.move(10 * math.sin(a * t), 10 * math.cos(a * t))
print(source.pos) #print(source.pos)
print(target.pos) #print(target.pos)
world.update_mirrors_from_source_target(source, target) world.update_mirrors_from_source_target(source, target)
print_status() print_status(world)
time.sleep(LOOP_DELAY) time.sleep(LOOP_DELAY)
t = time.time()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

37
tests/test_calculator.py Normal file
View File

@@ -0,0 +1,37 @@
import unittest
import numpy as np
import calculator
class TestCalculator(unittest.TestCase):
def test_proj(self):
vec = np.array([123, 325, 1])
self.assertEqual(np.array([123, 0, 0]).all(), calculator.proj(vec, 1).all())
vec = np.array([234, -2134, 12])
self.assertEqual(np.array([-2134, 0, 0]).all(), calculator.proj(vec, 2).all())
vec = np.array([-21, 34, 82])
self.assertEqual(np.array([0, 0, 82]).all(), calculator.proj(vec, 3).all())
def test_rotate(self):
vec = np.array([1, 0, 0])
self.assertEqual(np.array([0, 1, 0]).all(), calculator.rotate(vec).all())
def test_agl(self):
vec1 = np.array([1, 0, 0])
vec2 = np.array([1, 0, 0])
self.assertEqual(0, calculator.agl(vec1, vec2))
vec1 = np.array([1, 0, 0])
vec2 = np.array([0, 1, 0])
self.assertEqual(90, calculator.agl(vec1, vec2))
def test_get_angles(self):
source = np.array([0, 50, 0])
target = np.array([0, 50, 0])
self.assertEqual((90, 90), calculator.get_angles(source, target))
if __name__ == "__main__":
unittest.main()

2
uv.lock generated
View File

@@ -516,7 +516,7 @@ wheels = [
[[package]] [[package]]
name = "solarmotor" name = "solarmotor"
version = "0.1.0" version = "0.1.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "adafruit-blinka" }, { name = "adafruit-blinka" },