From f4d066d5db5917e39cbed18b5f576cb137afde1c Mon Sep 17 00:00:00 2001 From: XANTRONIX Industrial Date: Wed, 26 Feb 2025 14:18:42 -0500 Subject: [PATCH] Initial commit of hodograph.py --- lib/xmet/hodograph.py | 168 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 lib/xmet/hodograph.py diff --git a/lib/xmet/hodograph.py b/lib/xmet/hodograph.py new file mode 100644 index 0000000..9183837 --- /dev/null +++ b/lib/xmet/hodograph.py @@ -0,0 +1,168 @@ +import math +import cairo + +from typing import Iterable + +from xmet.igra import IGRAReader +from xmet.sounding import Sounding, SoundingSample + +IMAGE_WIDTH = 800 +IMAGE_HEIGHT = 800 + +GRAPH_WIDTH = IMAGE_WIDTH - 128 +GRAPH_HEIGHT = IMAGE_HEIGHT - 128 + +WIND_SPEED_MAX = 90 # knots +WIND_SPEED_MIN = 10 +WIND_SPEED_STEP = 10 + +WIND_DIR_STEP = 45 # degrees + +PRESSURE_MIN = 100 + +def radians(degrees: float) -> float: + return (degrees + 90) * (math.pi / 180.0) + +def degrees(radians: float) -> float: + return (radians * (180.0 / math.pi)) - 90 + +class Hodograph(): + def __init__(self, width, height): + self.width = min(width, height) + self.height = min(width, height) + self.radius = min(width, height) / 2 + + def sample_to_screen(self, wind_speed: dir, wind_dir: float) -> tuple: + r = self.radius * min(wind_speed, WIND_SPEED_MAX) / WIND_SPEED_MAX + + return ( + self.width / 2 + r * math.cos(radians(wind_dir)), + self.height / 2 + r * math.sin(radians(wind_dir)) + ) + + def draw_speed_lines(self, cr: cairo.Context, x, y): + for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX+1, WIND_SPEED_STEP): + cr.arc(x + self.width / 2, + y + self.height / 2, + self.radius * min(speed, WIND_SPEED_MAX) / WIND_SPEED_MAX, + 0, + 2*math.pi) + + cr.stroke() + + def draw_direction_lines(self, cr: cairo.Context, x, y): + for angle in range(0, 360+1, WIND_DIR_STEP): + sx1, sy1 = self.sample_to_screen(WIND_SPEED_MAX, angle) + sx2, sy2 = self.sample_to_screen(WIND_SPEED_MAX, angle + 180) + + cr.move_to(x + sx1, y + sy1) + cr.line_to(x + sx2, y + sy2) + cr.stroke() + + def draw_direction_legends(self, cr: cairo.Context, x, y): + cr.save() + + cr.select_font_face('Sans', + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_BOLD) + + cr.set_font_size(16) + + for angle in range(0, 360, WIND_DIR_STEP): + text = "%d°" % angle + extents = cr.text_extents(text) + r = self.radius + ((extents.width + extents.height) / 2) + + sx = self.width / 2 + r * math.cos(radians(angle)) + sy = self.height / 2 + r * math.sin(radians(angle)) + + cr.move_to(x + sx - extents.width / 2, y + sy + extents.height / 2) + cr.show_text(text) + cr.stroke() + + cr.restore() + + def draw_speed_legends(self, cr: cairo.Context, x, y): + cr.save() + + x_offset_scale = 0.01190476 + y_offset_scale = 0.02380952 + + x_offset = x_offset_scale * self.radius + y_offset = y_offset_scale * self.radius + + for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX, WIND_SPEED_STEP): + text = "%dkt" % speed + extents = cr.text_extents(text) + + sx, sy = self.sample_to_screen(speed, 180) + + cr.move_to(x + sx + x_offset, y + sy - y_offset) + cr.show_text(text) + cr.stroke() + + cr.restore() + + def color(self, height: float): + if height <= 500: + return (1, 0, 1) + elif height <= 3000: + return (1, 0, 0) + elif height <= 7000: + return (0, 1, 0) + elif height <= 10000: + return (0, 0, 1) + + def draw_samples(self, + cr: cairo.Context, + x, + y, + samples: Iterable[SoundingSample]): + cr.save() + + first = True + color_last = None + sx_last = None + sy_last = None + + for sample in samples: + if sample.pressure < 0 or sample.pressure is None: + continue + + if sample.pressure < PRESSURE_MIN: + break + + color = self.color(sample.height) + + if color is None: + break + elif color is not color_last: + cr.stroke() + cr.set_source_rgb(*color) + + if sx_last is not None and sy_last is not None: + cr.move_to(x + sx_last, x + sy_last) + + sx, sy = self.sample_to_screen(sample.wind_speed, sample.wind_dir) + + if first: + cr.move_to(x + sx, y + sy) + first = False + else: + cr.line_to(x + sx, y + sy) + + color_last = color + sx_last = sx + sy_last = sy + + cr.stroke() + cr.restore() + + def draw(self, cr: cairo.Context, x, y, samples: Iterable[SoundingSample]): + self.draw_speed_lines(cr, x, y) + self.draw_direction_lines(cr, x, y) + + self.draw_samples(cr, x, y, samples) + + self.draw_speed_legends(cr, x, y) + self.draw_direction_legends(cr, x, y)