Spacer po spirali

https://xpil.eu/Brmnu

Losowe (lub pseudolosowe) spacery to mój nieleczony zajob moje hobby, o którym uparcie od czasu do czasu tu piszę ku własnej rozrywce, a przerażeniu znudzeniu Czytelnika.

Dzisiaj jednak usuniemy z równania element losowy (i nawet pseudolosowy) - i rozwiążemy sobie geometryczne zadanie z elementami spaceru. Celem wpisu jest nie tyle samo zdanie, co zabawa w programowanie. Najpierw będziemy rysować, a potem trochę policzymy.

Zadanie brzmi następująco: startujemy z jakiegoś punktu na płaszczyźnie. Idziemy jeden metr do przodu. Zakręcamy w lewo o X stopni. Idziemy dwa metry do przodu. Zakręcamy w lewo o X stopni. Idziemy trzy metry do przodu. Zakręcamy w lewo o X stopni. Idziemy cztery metry do przodu. Zakręcamy...

Krótko mówiąc: w N-tym kroku przemieszczamy się o N metrów po linii prostej, a potem obracamy się o X stopni w lewo.

W pewnym momencie zauważamy, że po pewnej liczbie kroków udało nam się przeciąć pierwszy (ten jednometrowy) odcinek trasy naszego spaceru.

Ponieważ jesteśmy wybitnie uzdolnieni matematycznie, natychmiast zauważamy, że niechcący trafiliśmy na najmniejszą możliwą wartość kąta zakrętu X, przy której jest to możliwe.

Pytanie: ile wynosi X? Odpowiedzi należy udzielić z dokładnością do - dajmy na to - trzech miejsc dziesiętnych po przecinku. Albo ośmiu. Wsioryba.

Zadanie można rozwiązać co najmniej na dwa sposoby: analitycznie (i wtedy dostaniemy wynik pełen średnio skomplikowanej trygonometrii), albo przez symulację. Jako że moje gruczoły matematyczne są już dość mocno wydudkane, a także, ponieważ obiecałem, że będziemy kodować - w naturalny sposób wybieram bramkę numer dwa.

Mój pomysł jest następujący: zasymulować dużo spacerów o długości, dajmy na to, stu kroków, dla zakresu kąta X od, dajmy na to, 5° do 175°. Każdy spacer zapisać jako osobną klatkę animacji (warto też gdzieś wyświetlić aktualną wartość X na każdej klatce), a następnie poskładać klatki do, za przeproszeniem, kupy - i sobie tę animację obejrzeć. To powinno nam dać odpowiedź z dokładnością do (być może?) jednego stopnia. A potem napisać sobie osobny skrypt, który symuluje to samo, ale w węższym (za to "gęstszym") zakresie kątów.

Zaczniemy zatem od tej prostszej części.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation, FFMpegWriter

# Parametry
ile_kroków = 100
kąty = np.arange(5, 176, 1) #Zakręty, od 5 do 175 stopni, co 1 stopień

# Przygotowanie wykresu
wykres, oś = plt.subplots(figsize=(6, 6))
oś.set_aspect('equal') # Równe jednostki na obu osiach
oś.set_xlim(-200, 200) # Zakładamy, że 200 wystarczy
oś.set_ylim(-200, 200) # Co najwyżej część trasy ucieknie poza kadr

trasa, = oś.plot([], [], lw=1)
# Pojedyncza trasa spaceru (chwilowo pusta).
# Przecinek, żeby potem uniknąć indeksowania (trasa[0]) - bo plot() zwraca listę  a my potrzebujemy tylko jednego elementu.
# LW: grubość linii.

def generuj_ścieżkę(kąt):
    x, y = [0], [0] # x, y: aktualna pozycja
    kąt_bieżący = 0
    długość_kroku = 1
    for _ in range(ile_kroków):
        kąt_radiany = np.deg2rad(kąt_bieżący) # Konwersja stopni na radiany
        dx = długość_kroku * np.cos(kąt_radiany) # Przesunięcie x i y
        dy = długość_kroku * np.sin(kąt_radiany)
        x.append(x[-1] + dx) # Zapamiętujemy nową pozycję
        y.append(y[-1] + dy)
        kąt_bieżący += kąt # Zakręcamy
        długość_kroku += 1 # Wydłużamy krok
    return x, y # Zwracamy dwie listy

def klatka_animacji(numer_klatki):
    # funkcja wywoływana dla każdej klatki animacji
    kąt = kąty[numer_klatki]
    x, y = generuj_ścieżkę(kąt)
    trasa.set_data(x, y)
    oś.set_title(f"Kąt: {kąt}°")

    # Auto-zoom - nie polecam
    # margines = 1
    # xmin, xmax = min(x), max(x)
    # ymin, ymax = min(y), max(y)
    # oś.set_xlim(xmin - margines, xmax + margines)
    # oś.set_ylim(ymin - margines, ymax + margines)

    return trasa,

# Tworzymy animację
animacja = FuncAnimation(wykres, klatka_animacji, frames=len(kąty), blit=True)
# blit=True przyspiesza rendering, rysując tylko elementy, które się zmieniły od ostatniej klatki
# Idealne do animacji ze stałym tłem

# Zapisz animację
writer = FFMpegWriter(fps=20)
animacja.save("spacery.mp4", writer=writer)
plt.close()

print("Gotowe - sprawdź plik spacery.mp4")

Efekt:

Hmmm. Całkiem ładne, ale szczerze mówiąc gówno widać. Zmniejszamy liczbę kroków do 10 i zawężamy zakres kątów do 90-170. Robimy też zbliżenie:

Ha. Pauzując w okolicy 137 stopni widzimy, że czwarty odcinek dotyka pierwszego.

A więc możemy skrócić trasę do czterech kroków i zrobić jeszcze lepsze zbliżenie. A także nieco zagęścić kąty:

Wyraźnie widać, że styk następuje w okolicach 138 stopni - ale ciężko ustalić dokładniejszy kąt.

Tu przechodzimy do części drugiej, czyli metody numeryczne.

Pomysł jest następujący: napisać funkcję, która na wejściu dostaje kąt X, a na wyjściu zwróci jedynkę jeżeli czwarty odcinek trasy przecina pierwszy, lub minus jedynkę - jeżeli nie przecina. A następnie zapuścić jakąś interpolację kwadratową czy inną bisekcję, żeby znaleźć konkretny kąt z zadaną dokładnością.

from shapely.geometry import LineString
from scipy.optimize import brentq
import numpy as np

def generuj_trasę(kąt_zakrętu, liczba_kroków=4):
    punkty = [(0, 0)]
    kąt = 0
    długość_kroku = 1
    for _ in range(liczba_kroków):
        kąt_rad = np.deg2rad(kąt)
        dx = długość_kroku * np.cos(kąt_rad)
        dy = długość_kroku * np.sin(kąt_rad)
        nowy_punkt = (punkty[-1][0] + dx, punkty[-1][1] + dy)
        punkty.append(nowy_punkt)
        kąt += kąt_zakrętu
        długość_kroku += 1
    return [LineString([punkty[i], punkty[i+1]]) for i in range(len(punkty)-1)]

def przecina_czy_nie(phi):
    segments = generuj_trasę(phi)
    return 1 if segments[0].intersects(segments[3]) else -1


# szukamy kąta, w którym 2 linie przecinają się
szukany_kąt = brentq(przecina_czy_nie, 130, 140, xtol=1e-5)
# tu się dzieje magia właściwa: brentq szuka miejsca zerowego zadanej funkcji
# w odróżnieniu od tradycyjnej bisekcji, brentq używa interpolacji kwadratowej
# więc jest szybsza a także bezpieczeniejsza
# Parametry:
# 130, 140 - przedział startowy
# xtol: tolerancja (dokładność) - tu 0.00001
# przecina_czy_nie: funkcja, której miejsca zerowego szukamy

print(f"Przecięcie nastąpiło przy kącie ≈ {szukany_kąt:.5f}°")

Wynik:

Przecięcie nastąpiło przy kącie ≈ 138.59038°

Jak dla mnie jest to dokładność całkiem wystarczająca.

Bonusik: można wykazać, że w momencie styku odcinek numer jeden dotyka odcinka numer cztery dokładnie w jego połowie, a więc odcinki numer 2, 3, oraz połowa odcinka nr 4 tworzą trójkąt równoramienny o ramionach długości 2 i podstawie 3. Ale już mi zabrakło rozpędu, żeby to tutaj rozpisać.

Nudne, prawda?

https://xpil.eu/Brmnu

Leave a Comment

Komentarze mile widziane.

Jeżeli chcesz do komentarza wstawić kod, użyj składni:
[code]
tutaj wstaw swój kod
[/code]

Jeżeli zrobisz literówkę lub zmienisz zdanie, możesz edytować komentarz po jego zatwierdzeniu.