2025-02-26 14:18:42 -05:00
|
|
|
import math
|
|
|
|
import cairo
|
|
|
|
|
|
|
|
from xmet.igra import IGRAReader
|
2025-02-26 17:30:32 -05:00
|
|
|
from xmet.sounding import Sounding
|
2025-02-26 14:18:42 -05:00
|
|
|
|
2025-02-26 18:29:33 -05:00
|
|
|
WIND_SPEED_MAX = 140 # knots
|
2025-02-26 15:36:29 -05:00
|
|
|
WIND_SPEED_MIN = 10
|
|
|
|
WIND_SPEED_STEP = 10
|
2025-02-26 14:18:42 -05:00
|
|
|
|
2025-03-03 17:51:04 -05:00
|
|
|
WIND_DIR_STEP = 30 # degrees
|
2025-02-26 14:18:42 -05:00
|
|
|
|
|
|
|
def radians(degrees: float) -> float:
|
|
|
|
return (degrees + 90) * (math.pi / 180.0)
|
|
|
|
|
|
|
|
def degrees(radians: float) -> float:
|
|
|
|
return (radians * (180.0 / math.pi)) - 90
|
|
|
|
|
2025-02-26 18:25:22 -05:00
|
|
|
def knots(ms: float) -> float:
|
|
|
|
return ms * 1.944
|
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
class Hodograph():
|
|
|
|
def __init__(self, width, height):
|
2025-03-03 17:18:39 -05:00
|
|
|
self.width = min(width, height)
|
|
|
|
self.height = min(width, height)
|
|
|
|
self.radius = min(width, height) / 2
|
|
|
|
self.extents = None
|
2025-03-02 13:46:50 -05:00
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
def sample_to_screen(self, wind_speed: dir, wind_dir: float) -> tuple:
|
2025-03-03 17:18:39 -05:00
|
|
|
r = self.radius * (wind_speed / WIND_SPEED_MAX)
|
2025-03-02 16:27:28 -05:00
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
x = self.radius + r * math.cos(radians(wind_dir))
|
|
|
|
y = self.radius + r * math.sin(radians(wind_dir))
|
2025-03-02 16:27:28 -05:00
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
if self.extents is None:
|
|
|
|
return x, y
|
|
|
|
else:
|
|
|
|
min_x, min_y = self.extents['min']
|
|
|
|
max_x, max_y = self.extents['max']
|
2025-03-02 16:27:28 -05:00
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
box_width = max_x - min_x
|
|
|
|
box_height = max_y - min_y
|
2025-03-03 17:20:29 -05:00
|
|
|
box = max(box_width, box_height) * 1.5
|
2025-03-02 16:27:28 -05:00
|
|
|
|
2025-03-03 17:20:29 -05:00
|
|
|
#
|
|
|
|
# Ensure the data points are centered within the hodograph
|
|
|
|
# viewbox.
|
|
|
|
#
|
2025-03-03 17:18:39 -05:00
|
|
|
offset_x = self.width * ((box - box_width) / box) / 2
|
|
|
|
offset_y = self.height * ((box - box_height) / box) / 2
|
2025-03-02 16:27:28 -05:00
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
return (
|
|
|
|
offset_x + ((x - min_x) / box) * self.width,
|
|
|
|
offset_y + ((y - min_y) / box) * self.height
|
|
|
|
)
|
2025-03-02 16:27:28 -05:00
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
def draw_speed_lines(self, cr: cairo.Context, x, y):
|
2025-02-26 15:36:29 -05:00
|
|
|
cr.save()
|
|
|
|
|
|
|
|
cr.set_source_rgb(0.5, 0.5, 0.5)
|
|
|
|
cr.set_line_width(0.5)
|
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
cx, cy = self.sample_to_screen(0, 0)
|
|
|
|
|
|
|
|
for speed in range(WIND_SPEED_MIN, 2*WIND_SPEED_MAX, WIND_SPEED_STEP):
|
|
|
|
if speed % (WIND_SPEED_STEP*2) == 0:
|
|
|
|
cr.set_dash([1, 1], 0)
|
|
|
|
else:
|
|
|
|
cr.set_dash([5, 5], 1)
|
|
|
|
|
|
|
|
sx, sy = self.sample_to_screen(speed, 0)
|
|
|
|
|
|
|
|
cr.arc(x + cx,
|
|
|
|
x + cy,
|
2025-03-03 17:32:19 -05:00
|
|
|
abs(sy - cy),
|
2025-02-26 14:18:42 -05:00
|
|
|
0,
|
|
|
|
2*math.pi)
|
|
|
|
|
|
|
|
cr.stroke()
|
|
|
|
|
2025-02-26 15:36:29 -05:00
|
|
|
cr.restore()
|
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
def draw_direction_lines(self, cr: cairo.Context, x, y):
|
2025-02-26 15:36:29 -05:00
|
|
|
cr.save()
|
|
|
|
|
|
|
|
cr.set_source_rgb(0.5, 0.5, 0.5)
|
2025-03-03 17:18:39 -05:00
|
|
|
cr.set_line_width(0.5)
|
2025-02-26 15:36:29 -05:00
|
|
|
|
|
|
|
for angle in range(0, 360+1, WIND_DIR_STEP):
|
2025-03-03 17:18:39 -05:00
|
|
|
sx1, sy1 = self.sample_to_screen(2*WIND_SPEED_MAX, angle)
|
|
|
|
sx2, sy2 = self.sample_to_screen(2*WIND_SPEED_MAX, angle + 180)
|
|
|
|
|
|
|
|
if angle % 90 == 0:
|
|
|
|
cr.set_dash([1, 1], 0)
|
|
|
|
else:
|
|
|
|
cr.set_dash([5, 5], 1)
|
2025-02-26 14:18:42 -05:00
|
|
|
|
|
|
|
cr.move_to(x + sx1, y + sy1)
|
|
|
|
cr.line_to(x + sx2, y + sy2)
|
|
|
|
cr.stroke()
|
|
|
|
|
2025-02-26 15:36:29 -05:00
|
|
|
cr.restore()
|
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
def draw_speed_legends(self, cr: cairo.Context, x, y):
|
|
|
|
cr.save()
|
|
|
|
|
|
|
|
for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX, WIND_SPEED_STEP):
|
|
|
|
text = "%dkt" % speed
|
|
|
|
extents = cr.text_extents(text)
|
|
|
|
|
2025-03-03 17:50:43 -05:00
|
|
|
for angle in (0, 180):
|
|
|
|
sx, sy = self.sample_to_screen(speed, angle)
|
2025-02-26 14:18:42 -05:00
|
|
|
|
2025-03-03 17:50:43 -05:00
|
|
|
cr.move_to(x + sx + extents.width * 0.25,
|
|
|
|
y + sy - extents.height * 1.5)
|
2025-03-03 23:46:10 -05:00
|
|
|
|
2025-03-03 17:50:43 -05:00
|
|
|
cr.show_text(text)
|
2025-02-26 14:18:42 -05:00
|
|
|
|
|
|
|
cr.restore()
|
|
|
|
|
2025-03-03 22:15:10 -05:00
|
|
|
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)
|
|
|
|
|
|
|
|
cx, cy = self.sample_to_screen(0, 0)
|
|
|
|
|
|
|
|
cr.set_font_size(14)
|
|
|
|
|
|
|
|
text = "%d°" % 180
|
|
|
|
extents = cr.text_extents(text)
|
|
|
|
|
|
|
|
cr.move_to(x + cx - extents.width / 2,
|
|
|
|
y + 4 * extents.height)
|
|
|
|
|
|
|
|
cr.show_text(text)
|
|
|
|
cr.stroke()
|
|
|
|
|
|
|
|
text = "%d°" % 0
|
|
|
|
extents = cr.text_extents(text)
|
|
|
|
|
|
|
|
cr.move_to(x + cx - extents.width / 2,
|
|
|
|
y + self.height - 4 * extents.height)
|
|
|
|
|
|
|
|
cr.show_text(text)
|
|
|
|
cr.stroke()
|
|
|
|
|
|
|
|
cr.restore()
|
|
|
|
|
2025-02-26 15:13:28 -05:00
|
|
|
COLORS = {
|
|
|
|
500: (1.0, 0.4, 1.0),
|
|
|
|
3000: (1.0, 0.4, 0.4),
|
|
|
|
7000: (0.4, 1.0, 0.4),
|
|
|
|
10000: (0.4, 0.4, 1.0)
|
|
|
|
}
|
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
def color(self, height: float):
|
2025-02-26 15:13:28 -05:00
|
|
|
for key in self.COLORS:
|
|
|
|
if height < key:
|
|
|
|
return self.COLORS[key]
|
2025-02-26 14:18:42 -05:00
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
def each_significant_sample(self, sounding: Sounding):
|
|
|
|
for sample in sounding.samples:
|
|
|
|
if sample.pressure < 0 or sample.pressure is None:
|
|
|
|
continue
|
|
|
|
|
|
|
|
if sample.height is None:
|
|
|
|
continue
|
|
|
|
|
|
|
|
if self.color(sample.height) is None:
|
|
|
|
break
|
|
|
|
|
|
|
|
yield sample
|
|
|
|
|
|
|
|
def find_extents(self, sounding: Sounding):
|
|
|
|
min_x, min_y = None, None
|
|
|
|
max_x, max_y = None, None
|
|
|
|
|
|
|
|
first = True
|
|
|
|
|
|
|
|
for sample in self.each_significant_sample(sounding):
|
|
|
|
sx, sy = self.sample_to_screen(knots(sample.wind_speed),
|
|
|
|
sample.wind_dir)
|
|
|
|
|
|
|
|
if first:
|
|
|
|
min_x = sx
|
|
|
|
max_x = sx
|
|
|
|
min_y = sy
|
|
|
|
max_y = sy
|
|
|
|
|
|
|
|
first = False
|
|
|
|
else:
|
|
|
|
if min_x > sx:
|
|
|
|
min_x = sx
|
|
|
|
|
|
|
|
if min_y > sy:
|
|
|
|
min_y = sy
|
|
|
|
|
|
|
|
if max_x < sx:
|
|
|
|
max_x = sx
|
|
|
|
|
|
|
|
if max_y < sy:
|
|
|
|
max_y = sy
|
|
|
|
|
|
|
|
return {
|
|
|
|
'min': (min_x, min_y),
|
|
|
|
'max': (max_x, max_y)
|
|
|
|
}
|
|
|
|
|
2025-02-26 17:30:32 -05:00
|
|
|
def draw_sounding(self,
|
|
|
|
cr: cairo.Context,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
sounding: Sounding):
|
2025-02-26 14:18:42 -05:00
|
|
|
cr.save()
|
|
|
|
|
|
|
|
first = True
|
|
|
|
color_last = None
|
|
|
|
sx_last = None
|
|
|
|
sy_last = None
|
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
min_x, min_y = self.extents['min']
|
|
|
|
max_x, max_y = self.extents['max']
|
2025-02-26 14:18:42 -05:00
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
box = max(max_x - min_x, max_y - min_y)
|
2025-03-03 10:43:03 -05:00
|
|
|
|
2025-03-03 17:18:39 -05:00
|
|
|
for sample in self.each_significant_sample(sounding):
|
2025-02-26 14:18:42 -05:00
|
|
|
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)
|
|
|
|
|
2025-02-26 18:25:22 -05:00
|
|
|
sx, sy = self.sample_to_screen(knots(sample.wind_speed),
|
|
|
|
sample.wind_dir)
|
2025-02-26 14:18:42 -05:00
|
|
|
|
|
|
|
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()
|
|
|
|
|
2025-02-26 15:13:28 -05:00
|
|
|
def draw_height_legends(self, cr: cairo.Context, x, y):
|
|
|
|
width = 0.02380952 * self.width
|
|
|
|
interval = 0.05952380 * self.width
|
2025-02-26 15:36:29 -05:00
|
|
|
offset = 0.75 * self.width
|
|
|
|
|
|
|
|
cr.save()
|
|
|
|
cr.set_source_rgb(1, 1, 1)
|
|
|
|
|
2025-02-26 17:30:55 -05:00
|
|
|
cr.rectangle(offset + x - width * 0.75,
|
2025-02-26 15:36:29 -05:00
|
|
|
y + width,
|
|
|
|
interval * 4,
|
|
|
|
width * 3)
|
|
|
|
|
|
|
|
cr.fill()
|
|
|
|
|
|
|
|
cr.set_source_rgb(0.8, 0.8, 0.8)
|
2025-02-26 17:30:55 -05:00
|
|
|
cr.rectangle(offset + x - width * 0.75,
|
2025-02-26 15:36:29 -05:00
|
|
|
y + width,
|
|
|
|
interval * 4,
|
|
|
|
width * 3)
|
|
|
|
cr.stroke()
|
|
|
|
cr.restore()
|
2025-02-26 15:13:28 -05:00
|
|
|
|
|
|
|
for height in self.COLORS:
|
|
|
|
color = self.COLORS[height]
|
|
|
|
|
|
|
|
cr.save()
|
|
|
|
cr.set_source_rgb(*color)
|
2025-02-26 15:36:29 -05:00
|
|
|
cr.rectangle(offset + x, y + width * 1.5, width, width)
|
2025-02-26 15:13:28 -05:00
|
|
|
cr.fill()
|
|
|
|
cr.restore()
|
|
|
|
|
2025-02-26 15:36:29 -05:00
|
|
|
cr.rectangle(offset + x, y + width * 1.5, width, width)
|
2025-02-26 15:13:28 -05:00
|
|
|
cr.stroke()
|
|
|
|
|
2025-02-26 17:30:55 -05:00
|
|
|
text = "%dm" % (height) if height < 1000 else "%dkm" % (height /1000)
|
|
|
|
extents = cr.text_extents(text)
|
2025-02-26 16:11:00 -05:00
|
|
|
|
2025-02-26 17:30:55 -05:00
|
|
|
cr.move_to(offset + x - extents.width / 4, y + 3.5*width)
|
|
|
|
cr.show_text(text)
|
2025-02-26 15:13:28 -05:00
|
|
|
|
|
|
|
offset += interval
|
|
|
|
|
2025-02-26 17:30:32 -05:00
|
|
|
def draw(self, cr: cairo.Context, x, y, sounding: Sounding):
|
2025-03-03 17:18:39 -05:00
|
|
|
self.extents = self.find_extents(sounding)
|
2025-03-02 16:27:28 -05:00
|
|
|
|
2025-02-26 15:36:29 -05:00
|
|
|
cr.rectangle(x, y, self.width, self.height)
|
|
|
|
cr.clip()
|
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
self.draw_direction_lines(cr, x, y)
|
2025-03-03 17:18:39 -05:00
|
|
|
self.draw_speed_lines(cr, x, y)
|
2025-02-26 14:18:42 -05:00
|
|
|
|
2025-03-03 17:33:33 -05:00
|
|
|
self.draw_sounding(cr, x, y, sounding)
|
|
|
|
|
2025-02-26 14:18:42 -05:00
|
|
|
self.draw_speed_legends(cr, x, y)
|
2025-03-03 22:15:10 -05:00
|
|
|
self.draw_direction_legends(cr, x, y)
|
2025-02-26 15:13:28 -05:00
|
|
|
self.draw_height_legends(cr, x, y)
|
2025-02-26 15:36:29 -05:00
|
|
|
|
|
|
|
cr.reset_clip()
|