commit 98ed13132b478e0ae6bd9ccd6c671e2876e3696e Author: fruchti Date: Sun Dec 11 12:58:10 2022 +0100 Add plotting library, simple ODE plot diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..65326bb --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfa43b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ + +plots/* +!.gitkeep \ No newline at end of file diff --git a/lib/plot.py b/lib/plot.py new file mode 100644 index 0000000..6fddd06 --- /dev/null +++ b/lib/plot.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python + +import cairo +import numpy as np + + +class Paper: + def __init__(self, width, height, margins=None): + self.width = width + self.height = height + self.margins = { + 'top': 0, + 'right': 0, + 'bottom': 0, + 'left': 0 + } + if isinstance(margins, int) or isinstance(margins, float): + self.margins['top'] = margins + self.margins['right'] = margins + self.margins['bottom'] = margins + self.margins['left'] = margins + elif isinstance(margins, list) and (isinstance(margins[0], int) + or isinstance(margins[0], float)): + if len(margins) == 2: + self.margins['top'] = margins[0] + self.margins['bottom'] = margins[0] + self.margins['left'] = margins[1] + self.margins['right'] = margins[1] + elif len(margins) == 3: + self.margins['top'] = margins[0] + self.margins['left'] = margins[1] + self.margins['right'] = margins[1] + self.margins['bottom'] = margins[2] + elif len(margins) == 4: + self.margins['top'] = margins[0] + self.margins['right'] = margins[1] + self.margins['bottom'] = margins[2] + self.margins['left'] = margins[3] + else: + self.margins = margins + + @property + def content_width(self): + return self.width - self.margins['left'] - self.margins['right'] + + @property + def content_height(self): + return self.height - self.margins['top'] - self.margins['bottom'] + + def size(self, honour_margins=True): + if honour_margins: + return np.array([self.content_width, self.content_height]) + else: + return np.array([self.width, self.height]) + + def is_within(self, point, honour_margins=True): + return ((self.left(honour_margins) < point[0]) + and (point[0] < self.right(honour_margins)) + and (self.top(honour_margins) < point[1]) + and (point[1] < self.bottom(honour_margins))) + + def top_left(self, honour_margins=True): + if honour_margins: + return np.array([self.margins['left'], + self.height - self.margins['top']]) + else: + return np.array([0, self.height]) + + def bottom_left(self, honour_margins=True): + if honour_margins: + return np.array([self.margins['left'], + self.margins['bottom']]) + else: + return np.array([0, 0]) + + def top_right(self, honour_margins=True): + if honour_margins: + return np.array([self.width - self.margins['right'], + self.height - self.margins['top']]) + else: + return np.array([self.width, self.height]) + + def bottom_right(self, honour_margins=True): + if honour_margins: + return np.array([self.width - self.margins['right'], + self.margins['bottom']]) + else: + return np.array([self.width, 0]) + + def centre(self, honour_margins=True): + return (self.bottom_left(honour_margins) + + self.top_right(honour_margins)) / 2 + + def top(self, honour_margins=True): + if honour_margins: + return self.margins['top'] + else: + return 0 + + def left(self, honour_margins=True): + if honour_margins: + return self.margins['left'] + else: + return 0 + + def right(self, honour_margins=True): + if honour_margins: + return self.width - self.margins['right'] + else: + return self.width + + def bottom(self, honour_margins=True): + if honour_margins: + return self.height - self.margins['bottom'] + else: + return self.height + + +A4_PORTRAIT = Paper(210, 297, 20) +A6_PORTRAIT = Paper(105, 148, 8) + + +class MultiPlotter: + def __init__(self): + self.plotters = [] + + def move_to(self, point): + for plotter in self.plotters: + plotter.move_to(point) + + def line_to(self, point): + for plotter in self.plotters: + plotter.line_to(point) + + def add_layer(self, *args, **kwargs): + for plotter in self.plotters: + plotter.add_layer(*args, **kwargs) + + def finalise(self): + for plotter in self.plotters: + plotter.finalise() + + def register_plotter(self, plotter): + self.plotters.append(plotter) + + +class Plotter: + + def __init__(self, paper, line_width=0, colour=[0, 0, 0, 1]): + self.paper = paper + self.pen_down = False + self.position = np.array([0, 0]) + + self.layers = [] + self.add_layer(colour, line_width) + + def add_layer(self, colour=[0, 0, 0, 1], line_width=0.5, name='', + switch=True): + self.layers.append({ + 'line_width': line_width, + 'colour': colour, + 'name': name + }) + if switch: + self.switch_layer(len(self.layers) - 1) + return len(self.layers) - 1 + + def switch_layer(self, layer): + self.current_layer = layer + + def move_to(self, point): + self.pen_down = False + self.position = point + + def line_to(self, point): + self.pen_down = True + self.position = point + + +class SVGPlotter(Plotter): + + def __init__(self, file_name, paper, line_width=0.5, colour=[0, 0, 0, 1]): + dpi = 72 + self.file_name = file_name + self.surface = cairo.SVGSurface(file_name, + paper.width / 25.4 * dpi, + paper.height / 25.4 * dpi) + self.context = cairo.Context(self.surface) + self.context.scale(dpi / 25.4, dpi / 25.4) + self.context.set_line_cap(cairo.LINE_CAP_ROUND) + + super().__init__(paper, line_width, colour) + + def move_to(self, point): + if self.pen_down: + self.context.stroke() + self.context.move_to(point[0], point[1]) + super().move_to(point) + + def line_to(self, point): + self.context.line_to(point[0], point[1]) + super().line_to(point) + + def finalise(self): + if self.pen_down: + self.context.stroke() + self.pen_down = False + self.surface.finish() + + def switch_layer(self, layer): + if self.pen_down: + self.context.stroke() + + super().switch_layer(layer) + self.context.set_source_rgba( + *self.layers[self.current_layer]['colour']) + self.context.set_line_width( + self.layers[self.current_layer]['line_width']) + + return len(self.layers) - 1 + + +class HPGLPlotter(Plotter): + + def __init__(self, paper, file_name_pattern): + self.file_name_pattern = file_name_pattern + self.resolution = (4000 / 81.5, 4000 / 80.5) + self.file = None + super().__init__(paper) + + def _layer_file_name(self, index): + return self.file_name_pattern.format( + index=index, + name=self.layers[index]['name'], + colour=self.layers[index]['colour'] + ) + + def _mm_to_plotter(self, mm): + p = (mm[0] * self.resolution[0], + (self.paper.height - mm[1]) * self.resolution[1]) + return f'{p[0]:.0f},{p[1]:.0f}' + + def move_to(self, point): + if self.pen_down: + self.file.write(';PU') + else: + self.file.write(',') + self.file.write(self._mm_to_plotter(point)) + return super().move_to(point) + + def line_to(self, point): + if not self.pen_down: + self.file.write(';PD') + else: + self.file.write(',') + self.file.write(self._mm_to_plotter(point)) + return super().line_to(point) + + def add_layer(self, colour='black', line_width=0.5, name='', switch=True): + new_index = super().add_layer(colour=colour, line_width=line_width, + name=name, switch=False) + layer_file = open(self._layer_file_name(new_index), 'wt') + + layer_file.write('IN;PU0,0') + + if switch: + if self.file: + self.file.close() + self.file = layer_file + self.current_layer = len(self.layers) - 1 + else: + layer_file.close() + + return len(self.layers) - 1 + + def switch_layer(self, layer): + if self.pen_down: + self.file.write(';PU') + self.pen_down = False + self.file.close() + super().switch_layer(layer) + self.file = open(self._layer_file_name(self.current_layer), 'at') + + def finalise(self): + self.file.close() + for i in range(len(self.layers)): + with open(self._layer_file_name(i), 'at') as file: + file.write(';PU0,0;IN') diff --git a/ode.ipynb b/ode.ipynb new file mode 100644 index 0000000..701b6d5 --- /dev/null +++ b/ode.ipynb @@ -0,0 +1,108 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "from lib import plot\n", + "import IPython\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "a = plot.A6_PORTRAIT\n", + "p_svg = plot.SVGPlotter('plots/ode.svg', a)\n", + "p_hpgl = plot.HPGLPlotter(a, 'plots/ode_{index}.hpgl')\n", + "p = plot.MultiPlotter()\n", + "p.register_plotter(p_svg)\n", + "p.register_plotter(p_hpgl)\n", + "\n", + "p.move_to(a.bottom_left())\n", + "p.line_to(a.bottom_right())\n", + "p.line_to(a.top_right())\n", + "p.line_to(a.top_left())\n", + "p.line_to(a.bottom_left())\n", + "\n", + "A = np.array([[-0.4, -0.2], [0.1, -0.3]])\n", + "A = np.random.normal(size=(2, 2))\n", + "\n", + "def plot_ode(A):\n", + " dia = np.min(a.size()) * 0.75\n", + " # step_size = 0.01 / np.max(np.abs(np.linalg.eig(A)[0]))\n", + "\n", + " states = np.linspace(0, 2 * np.pi, 24)\n", + " states = np.vstack([np.cos(states), np.sin(states)])\n", + " for s in states.T:\n", + " p.move_to(s * dia / 2 + a.centre())\n", + " for _ in range(100000):\n", + " step = A @ s\n", + " step *= (0.5 / dia) / np.linalg.norm(step)\n", + " s = step + s\n", + " n = s * dia / 2 + a.centre()\n", + " if not a.is_within(n):\n", + " break\n", + " if np.linalg.norm(s * dia / 2) < 0.5:\n", + " break\n", + " p.line_to(n)\n", + "\n", + "p.add_layer([1, 0, 1, 0.5])\n", + "plot_ode(A)\n", + "p.add_layer([1, 0, 0, 0.5])\n", + "plot_ode(-A)\n", + "# A = np.linalg.inv(A)\n", + "# plot_ode(A)\n", + "# plot_ode(-A)\n", + "\n", + "p.finalise()\n", + "\n", + "IPython.display.SVG(filename=p_svg.file_name)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.9.15 64-bit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.15" + }, + "orig_nbformat": 4, + "vscode": { + "interpreter": { + "hash": "1dc4d77b1edb83bef89f833b7ed5251134c6a4899ef5e2c90c44e9927b4ae63a" + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/plots/.gitkeep b/plots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..e8d9bce --- /dev/null +++ b/shell.nix @@ -0,0 +1,15 @@ +{ pkgs ? import {} }: +let + pythonWithPackages = pkgs.python3.withPackages (p: with p; [ + pycairo + numpy + jupyter + ipykernel + matplotlib + scipy + opencv4 + noise + autopep8 + ]); +in +pythonWithPackages.env \ No newline at end of file