Eine Kachel, mehr braucht es nicht

surt91 hat mich eingeladen, hier einen Gastbeitrag zu schreiben, und mir das Thema freigestellt. Ich habe mich für eine kleine geometrische Spielerei entschieden, die aus einer einzigen Regel erstaunlich hübsche Muster macht: Truchet-Kacheln.

Es gibt nämlich nur eine einzige Kachel. Sie ist quadratisch und trägt zwei Viertelkreise, die je zwei benachbarte Seitenmitten verbinden — mehr nicht. Für ihre Lage auf dem Gitter gibt es genau zwei Möglichkeiten:

Die beiden Drehungen der einen Kachel: die Viertelkreise umarmen entweder die obere linke und untere rechte Ecke oder die beiden anderen

Man legt viele Kopien auf ein Gitter und würfelt für jede aus, welche der beiden sie einnimmt. Aus dieser fast schon albern simplen Vorschrift fällt das hier heraus:

Truchet-Kacheln aus zufällig gedrehten Viertelkreisen

Mich fasziniert das jedes Mal aufs Neue. Nichts an der Regel weiß irgendetwas von Schlaufen, Symmetrie oder geschlossenen Kurven, und trotzdem finden die Viertelkreise über die Kachelgrenzen hinweg zueinander und verweben sich zu einem sauberen Geflecht. Die Idee ist alt: Der Dominikanerpater Sébastien Truchet hat schon 1704 systematisch durchgespielt, welche Muster entstehen, wenn man ein einzelnes Quadrat in allen seinen Drehungen kombiniert. Seine Originalkachel war allerdings noch ein schlicht diagonal in Schwarz und Weiß geteiltes Quadrat; die geschwungene Variante mit den Viertelkreisen, die zu diesen fließenden Linien führt, geht auf Cyril Stanley Smith zurück, der die Truchet-Kacheln 1987 wieder ausgegraben hat.

Der ganze Zauber passt in eine Handvoll Zeilen Python, die direkt ein SVG ausspucken — kein numpy, kein Plot-Framework, nur etwas Geometrie und random:

import random

def truchet(filename, n=16, s=40, seed=42, stroke_ratio=0.18):
    random.seed(seed)
    w = h = n * s
    r, sw = s / 2, s * stroke_ratio
    paths = []
    for j in range(n):
        for i in range(n):
            x, y = i * s, j * s
            tm = (x + r, y)      # top middle
            rm = (x + s, y + r)  # right middle
            bm = (x + r, y + s)  # bottom middle
            lm = (x, y + r)      # left middle
            if random.random() < 0.5:
                # Bögen umarmen die obere linke und untere rechte Ecke
                arcs = [(tm, lm, 1), (bm, rm, 1)]
            else:
                # ... oder die obere rechte und untere linke
                arcs = [(tm, rm, 0), (lm, bm, 1)]
            for (ax, ay), (bx, by), sweep in arcs:
                paths.append(f'M{ax:.1f} {ay:.1f} '
                             f'A{r:.1f} {r:.1f} 0 0 {sweep} {bx:.1f} {by:.1f}')
    body = '\n'.join(f'  <path d="{d}"/>' for d in paths)
    svg = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" '
           f'width="{w}" height="{h}">\n'
           f'<g fill="none" stroke="#000" stroke-width="{sw:.1f}" '
           f'stroke-linecap="round">\n{body}\n</g>\n</svg>\n')
    with open(filename, 'w') as f:
        f.write(svg)

truchet('truchet.svg')

Pro Kachel werden zwei Viertelkreis-Bögen als SVG-path gezeichnet, und die einzige Entscheidung ist der Münzwurf, welches Eckenpaar sie umarmen. Die 1 beziehungsweise 0 ist das sweep-flag des Bogens und sorgt nur dafür, dass er sich nach innen wölbt statt nach außen. Das war der gesamte Algorithmus.

Wenn man genau hinschaut, sind die wirren Schnörkel in Wahrheit überhaupt nicht wirr: An jeder Seitenmitte des Gitters stoßen immer genau zwei Bögen aufeinander — einer aus jeder der beiden angrenzenden Kacheln. Jeder Strich hat also exakt zwei Nachbarn, und damit zerfällt das ganze Gewimmel sauber in eine Handvoll geschlossener Schlaufen. Gibt man jeder ihre eigene Farbe — ein kurzer Union-Find über die Kanten genügt —, sieht man, wie unterschiedlich lang sie ausfallen:

Dieselbe Kachelung, jede geschlossene Schlaufe in eigener Farbe

Manche Loops mäandern einmal quer über das ganze Bild, andere sind zu einem einzelnen kleinen Kreis zusammengeschnurrt. Welche von beidem — das entscheidet allein der Zufall im seed.

Als ich surt91 die fertige Kachelung gezeigt habe, war seine erste Reaktion: das erinnert an Perkolation. Er hat recht — die zufällige Bogen-Kachelung ist sogar eine der klassischen Arten, kritische Perkolation überhaupt zu zeichnen. Weil jede Kachel immer genau zwei Bögen trägt, sind die bunten Schlaufen von eben nichts anderes als die Umrisse — die hulls — von Perkolations-Clustern. Und der faire Münzwurf mit Wahrscheinlichkeit $1/2$ landet wegen Selbstdualität punktgenau auf dem kritischen Punkt $p_c = 1/2$ des Quadratgitters. Genau deshalb sieht man Loops auf allen Größen — am Phasenübergang ist das System skaleninvariant.

Damit erbt die Größenverteilung der Schlaufen alles, was man über kritische Perkolation weiß. Die Loops sind Fraktale der Dimension $7/4$ — das Perkolations-Gegenstück zu der $187/96$, die hier schon beim Ising-Modell auftauchte. Die Zahl der Schlaufen, die eine Fläche größer als $A$ umschließen, ist sogar exakt und universell bekannt, nämlich $\frac{1}{8\pi\sqrt{3}}\,\frac{1}{A}$ — ein hübsches Resultat von Cardy und Ziff. Und das Schönste, weil es surt91s Bauchgefühl unmittelbar bestätigt: Verzieht man die Münze, sodass eine Orientierung häufiger fällt, verlässt man den kritischen Punkt. Die großen, quer durchs Bild mäandernden Loops verschwinden dann zugunsten einer charakteristischen Maximalgröße — nur bei exakt $1/2$ reicht das Spektrum bis zur Bildkante. Im Generator ist das ein Einzeiler: random.random() < p statt < 0.5.

Für ein eigenes GitHub-Repo ist das alles entschieden zu kurz; der vollständige Code steht ja schon oben im Beitrag. Eine andere Zahl im seed, und man hat sein nächstes Hintergrundbild. Und damit reiht sich auch dieser Gastbeitrag brav in die altbekannte Vorliebe dieses Blogs für schwarz-weiße Bilder aus Linien und Kreisen ein.

Osteralbtraum

Ostern ist ein Feiertag, dessen Zeitpunkt mit einer Regel festgelegt wird, die unnötig kompliziert scheint. Der erste Sonntag nach dem ersten Vollmond im Frühling. Den meisten bleibt da als Lösung nicht viel mehr übrig als in einem Kalender nachzusehen welches Datum es denn wohl ist und sich auf den Kalenderhersteller zu verlassen. Aber nicht mit mir! Ich werde es Big-Calendar zeigen und hier die geheime Formel veröffentlichen, mit der man das Osterdatum berechnet!

from datetime import date

def easter(year: int) -> date:
    y = year
    g = y % 19 + 1                    # golden number
    c = y // 100 + 1                  # century
    x = (3 * c) // 4 - 12             # correction: dropped leap years
    z = (8 * c + 5) // 25 - 5         # correction: synchronize with moon's orbit
    d = (5 * y) // 4 - x - 10         # find sunday
    e = (11 * g + 20 + z - x) % 30    # epact
    if e == 25 and g > 11 or e == 24:
        e += 1
    n = 44 - e                        # full moon in march
    if n < 21:
        n += 30
    n = n + 7 - (d + n) % 7           # advance to next sunday
    month, day = (4, n - 31) if n > 31 else (3, n)

    return date(year, month, day)

Mir persönlich gefällt besonders gut, dass jede Zeile schlimmer ist als die vorherige.

Dieser Algorithmus ist übrigens von Lilius und Clavius Ende des 16. Jahrunderts entwickelt worden. Ich bin durch eine Erwähnung in einer Übungsaufgabe in Donald Knuths The Art of Computer Programming 1 (Third edition, S. 159f) darauf gestoßen.

pirShow

Alte Monitore sind zu Schade zum Entsorgen. Als Upcycling habe ich deshalb einen alten Monitor zu einem digitalen Bilderrahmen in meinem Flur umfunktioniert. Sinnvollerweise sollte er natürlich nur dann ein Bild zeigen, wenn auch jemand da ist, der es betrachten kann. Hier möchte ich einmal kurz beschreiben, wie ich einen Raspberry Pi, einen passiven Bewegungssensor und etwas Python-Code zu diesem Zweck benutze.

Bildquellen definieren

Die Hauptfunktionalität eines digitalen Bilderrahmens ist es natürlich Bilder anzuzeigen. Diese Bilder sollen aus mehreren Quellen zufällig ausgewählt werden. Dabei habe ich mir einige Flickr-Accounts über Raumfahrt und meine Twitter-Bots ausgesucht. Zuerst brauchen wir also etwas Code, um die Bilder herunterzuladen.

def flickr(user_id):
    import flickrapi
    from keys_and_secrets import keys_and_secrets

    url_template = 'http://farm%(farm_id)s.staticflickr.com/%(server_id)s/%(photo_id)s_%(secret)s_b.jpg'

    def url_for_photo(p):
        return url_template % {
            'server_id': p.get('server'),
            'farm_id': p.get('farm'),
            'photo_id': p.get('id'),
            'secret': p.get('secret'),
        }

    flickr = flickrapi.FlickrAPI(keys_and_secrets["flickr_key"], keys_and_secrets["flickr_secret"])

    photo = random.choice(flickr.photos.search(user_id=user_id, per_page=500)[0])
    purl = url_for_photo(photo)
    title = photo.get('title')

    fname = save_image(purl, title)

    return fname


def twitter(atname):
    import tweepy
    from keys_and_secrets import keys_and_secrets

    auth = tweepy.OAuthHandler(keys_and_secrets["consumer_key"], keys_and_secrets["consumer_secret"])
    auth.set_access_token(keys_and_secrets["access_token_key"], keys_and_secrets["access_token_secret"])

    api = tweepy.API(auth)
    tweets = api.user_timeline(atname, count=30)

    urls = []
    for i in tweets:
        if "media" in i.entities:
            for j in i.entities["media"]:
                url = j["media_url"]
                if "thumb" not in url:
                    urls.append((url, i.id_str))

    purl, title = random.choice(urls)

    fname = save_image(purl, title)

    return fname

Dann bauen wir uns einen praktischen Decorator, den wir nutzen, um unterschiedliche Accounts als Bildquellen zu registrieren.

sources = {}

def source(source_name):
    def source_decorator(func):
        sources[source_name] = func

        @wraps(func)
        def func_wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return func_wrapper
    return source_decorator


@source("apollo")
def apollo():
    return flickr("projectapolloarchive")


@source("randomGraphs")
def randomGraphs():
    return twitter("@randomGraphs")


@source("AFractalADay")
def AFractalADay():
    return twitter("@AFractalADay")

Dies ermöglicht es dann sehr komfortabel zufällige Bilder herunterzuladen und anzuzeigen.

def random_image():
    image_getter = random.choice(list(sources.values()))
    fname = image_getter()
    # show image
    # skipped terminating old instance of feh and aquiring a mutex
    env = os.environ
    env["DISPLAY"] = ":0"
    VIEWER = subprocess.Popen(
        ["feh", "-FZYx", fname],
        env=env,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )

Monitor ein- und ausschalten

Jetzt, da wir Bilder zum Anzeigen haben, müssen wir den Monitor ein- und ausschalten, damit man sie sieht bzw. damit wir nicht sinnlos Strom verbrauchen. Hier rufen wir wieder Kommandozeilen-Werkzeuge auf: tvservice schaltet den Standby-Modus des Monitors um und chvt wechselt einmal vom X-Server weg und wieder zurück, was den Bildschirmschoner beendet.

from threading import Timer, Lock

MUTEX = Lock()
STATE = False

def monitor(status):
    global STATE
    # needs to run as root
    # make sure that sudo will not ask for a password for these commands
    # e.g. use visudo to add
    # piruser ALL=(ALL) NOPASSWD: /usr/bin/tvservice, /bin/chvt
    with MUTEX:
        if status:
            if not STATE:
                os.system("sudo tvservice -p; sleep 0.5; sudo chvt 6; sleep 0.5; sudo chvt 7")
            STATE = True
        else:
            if STATE:
                os.system("sudo tvservice -o")
                # download and show the next image
                random_image()
            STATE = False

PIR

Jetzt müssen wir diese Funktionalität nur noch durch einen Bewegungssensor auslösen. Dazu schließen wir einfach einen Pyroelektrischen Infrarot Sensor (PIR) an beispielsweise Pin 23 und sagen dem Raspberry, dass er dort horchen soll, ob ein Signal anliegt.

Ein PIR Bewegungsensor, der an einen Raspberry Pi angeschlossen ist

import RPi.GPIO as GPIO

SENSOR_PIN = 23

GPIO.setmode(GPIO.BCM)
GPIO.setup(SENSOR_PIN, GPIO.IN)

if __name__ == "__main__":
    GPIO.add_event_detect(SENSOR_PIN, GPIO.RISING, callback=pir_callback)

Der pir_callback schaltet dann einfach den Monitor an und startet einen Timer, der den Monitor wieder ausstellt (dieser Timer wird abgebrochen sobald der Callback erneut aufgerufen wird, damit der Monitor an bleibt, solange jemand das Bild betrachtet.)

Der pir_callback sendet außerdem auch eine MQTT-Nachricht, um die Bewegungsmeldung auch für Home-Assistant-Automatisierungen zu nutzen, sodass die Beleuchtung im Flur nach Sonnenuntergang nun auch durch Bewegungen ausgelöst wird.