atmospheris 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.adoc +274 -4
- data/lib/atmospheris/export/iso_5878/atmosphere_profile_export.rb +86 -0
- data/lib/atmospheris/export/iso_5878/wind_table_export.rb +78 -0
- data/lib/atmospheris/export/iso_5878.rb +40 -0
- data/lib/atmospheris/export.rb +1 -0
- data/lib/atmospheris/iso5878/atmosphere_profile.rb +216 -0
- data/lib/atmospheris/iso5878/model_registry.rb +99 -0
- data/lib/atmospheris/iso5878/rice_distribution.rb +77 -0
- data/lib/atmospheris/iso5878/surface_parameters.rb +69 -0
- data/lib/atmospheris/iso5878/wind_observation.rb +84 -0
- data/lib/atmospheris/iso5878.rb +188 -0
- data/lib/atmospheris/version.rb +1 -1
- data/lib/atmospheris.rb +1 -0
- metadata +21 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82f15c900161ab68525b236a39381bfaab2b04319299b2141f4f2e4c31096201
|
|
4
|
+
data.tar.gz: fe1733ba6452b666a0cc8d0a02706eae8ec8eb74e725ee950d7ed7ccf0c30bdf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3dd8fc1684642111da82a16c92c761fb8ba962b7514f3ad0fbf1b1b7c3568a529a2c649647758af16e69fdb812e6ce8518c542bec89c1ec8efa284f301b7b17
|
|
7
|
+
data.tar.gz: 5317c6deb64a6cf1ffc7b9ff276bafd91f31df37d52d76c0827043263a730d530497ab10d44b51ddff5b72f64dec0e523ffb8a840dc0a59970679c4c67a74a26
|
data/README.adoc
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
= Atmospheris for Ruby (ISO Standard Atmosphere /
|
|
1
|
+
= Atmospheris for Ruby (ISO 2533 Standard Atmosphere / ISO 5878 Reference Atmospheres)
|
|
2
2
|
|
|
3
3
|
== Purpose
|
|
4
4
|
|
|
@@ -8,12 +8,15 @@ following standards:
|
|
|
8
8
|
* International Standard Atmosphere (ISA) from ISO 2533:1975,
|
|
9
9
|
ISO 2533:1975/ADD 1:1985 and ISO 2533:1975/ADD 2:1997
|
|
10
10
|
* https://store.icao.int/en/manual-of-the-icao-standard-atmosphere-extended-to-80-kilometres-262500-feet-doc-7488[ICAO Standard Atmosphere (ICAO Doc 7488/3, 1994)]
|
|
11
|
+
* ISO 5878 reference atmospheres for aerospace use, including wind
|
|
12
|
+
distribution calculations (Rice/circular normal distribution)
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
units (the ICAO document includes `ft` in
|
|
14
|
+
The ISO 2533 and ICAO Doc 7488/3 standards are technically identical but
|
|
15
|
+
different in presentation and units (the ICAO document includes `ft` in
|
|
16
|
+
addition to `m`).
|
|
14
17
|
|
|
15
18
|
This library serves as a reference implementation for the values defined in
|
|
16
|
-
ISO CD 2533:2025.
|
|
19
|
+
ISO CD 2533:2025 and ISO/DIS 5878.
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
== Usage
|
|
@@ -577,6 +580,273 @@ speed_n.class
|
|
|
577
580
|
====
|
|
578
581
|
|
|
579
582
|
|
|
583
|
+
=== ISO 5878 — Wind characteristics
|
|
584
|
+
|
|
585
|
+
The library implements wind speed distribution calculations per ISO 5878
|
|
586
|
+
(Reference atmospheres for aerospace use). Wind speed is modeled using the
|
|
587
|
+
*circular normal (Rice) distribution*, which derives scalar wind speed
|
|
588
|
+
statistics from observed vector components.
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
==== Quick start — Wind distribution
|
|
592
|
+
|
|
593
|
+
[source,ruby]
|
|
594
|
+
----
|
|
595
|
+
require 'atmospheris'
|
|
596
|
+
|
|
597
|
+
# Compute derived wind characteristics from observed parameters
|
|
598
|
+
wind = Atmospheris::Iso5878.compute_wind_derived(-3.9, -1.2, 5.9)
|
|
599
|
+
|
|
600
|
+
wind.vr #=> 4.08 — vector mean wind magnitude (m/s)
|
|
601
|
+
wind.vsc #=> 6.03 — scalar mean wind speed (m/s)
|
|
602
|
+
wind.sigma #=> 4.17 — per-component standard deviation (m/s)
|
|
603
|
+
|
|
604
|
+
# Percentile wind speeds
|
|
605
|
+
wind.percentiles[1].high #=> ~14.7 (not exceeded on 99% of occasions)
|
|
606
|
+
wind.percentiles[10].low #=> ~0.5
|
|
607
|
+
wind.percentiles[10].high #=> ~12.0
|
|
608
|
+
wind.percentiles[20] #=> PercentilePair with low and high
|
|
609
|
+
----
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
==== WindObservation
|
|
613
|
+
|
|
614
|
+
The `Atmospheris::Iso5878::WindObservation` class encapsulates a single
|
|
615
|
+
altitude-level wind observation with its empirically measured parameters
|
|
616
|
+
and lazily computed derived statistics.
|
|
617
|
+
|
|
618
|
+
[source,ruby]
|
|
619
|
+
----
|
|
620
|
+
require 'atmospheris'
|
|
621
|
+
|
|
622
|
+
obs = Atmospheris::Iso5878::WindObservation.new(
|
|
623
|
+
geopotential_altitude: 1000,
|
|
624
|
+
vx: -3.9,
|
|
625
|
+
vy: -1.2,
|
|
626
|
+
sigma_r: 5.9,
|
|
627
|
+
vsa: 7.6 # optional: observed scalar mean speed
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
obs.vr #=> 4.08
|
|
631
|
+
obs.theta #=> direction in radians from east
|
|
632
|
+
obs.vsc #=> 6.03 (calculated)
|
|
633
|
+
obs.percentile_bounds[1].high #=> ~14.7
|
|
634
|
+
obs.derived_fields #=> WindDerivedFields struct
|
|
635
|
+
----
|
|
636
|
+
|
|
637
|
+
Parameters:
|
|
638
|
+
|
|
639
|
+
* `geopotential_altitude` — Altitude in metres
|
|
640
|
+
* `vx` — Mean zonal wind component (m/s)
|
|
641
|
+
* `vy` — Mean meridional wind component (m/s)
|
|
642
|
+
* `sigma_r` — Standard deviation of vector mean wind (m/s)
|
|
643
|
+
* `vsa` — (optional) Observed scalar mean speed (m/s)
|
|
644
|
+
* `nu_max` — (optional) Max observed speed once in 10 years (m/s)
|
|
645
|
+
* `use_absolute_vx` — (default: false) For zones > 20°N where Vy ≈ 0
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
==== RiceDistribution
|
|
649
|
+
|
|
650
|
+
The `Atmospheris::Iso5878::RiceDistribution` class encapsulates a Rice
|
|
651
|
+
distribution with fixed parameters (Vr, sigma_r), providing lazy-cached
|
|
652
|
+
statistical computations.
|
|
653
|
+
|
|
654
|
+
[source,ruby]
|
|
655
|
+
----
|
|
656
|
+
require 'atmospheris'
|
|
657
|
+
|
|
658
|
+
dist = Atmospheris::Iso5878::RiceDistribution.new(vr: 4.08, sigma_r: 5.9)
|
|
659
|
+
|
|
660
|
+
dist.mean #=> 6.03 (scalar mean wind speed Vsc)
|
|
661
|
+
dist.pdf(5.0) #=> probability density at 5 m/s
|
|
662
|
+
dist.cdf(10.0) #=> P(wind ≤ 10 m/s)
|
|
663
|
+
dist.quantile(0.99) #=> ~14.7 (wind speed exceeded on 1% of occasions)
|
|
664
|
+
dist.percentile_bounds #=> { 1 => Pair, 10 => Pair, 20 => Pair }
|
|
665
|
+
----
|
|
666
|
+
|
|
667
|
+
Methods:
|
|
668
|
+
|
|
669
|
+
* `pdf(nu)` — Probability density at wind speed nu
|
|
670
|
+
* `cdf(x)` — Cumulative distribution function
|
|
671
|
+
* `quantile(p)` — Inverse CDF (quantile function)
|
|
672
|
+
* `mean` — Analytical Rice distribution mean (Vsc, Eq. 4)
|
|
673
|
+
* `percentile_bounds` — Hash mapping percentage (1, 10, 20) to `PercentilePair`
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
==== Low-level functions
|
|
677
|
+
|
|
678
|
+
Module-level functions in `Atmospheris::Iso5878` for direct access to the
|
|
679
|
+
underlying mathematical operations:
|
|
680
|
+
|
|
681
|
+
[cols="3,5",grid="none"]
|
|
682
|
+
|===
|
|
683
|
+
|Method |Description
|
|
684
|
+
|
|
685
|
+
|`bessel_i0(x)` |Modified Bessel function of the first kind, order zero
|
|
686
|
+
|`bessel_i1(x)` |Modified Bessel function of the first kind, order one
|
|
687
|
+
|`rice_pdf(nu, vr, sigma_r)` |Rice distribution PDF (Eq. 3)
|
|
688
|
+
|`rice_cdf(x, vr, sigma_r)` |Rice distribution CDF (adaptive Simpson quadrature)
|
|
689
|
+
|`rice_inv_cdf(p, vr, sigma_r)` |Rice inverse CDF via bisection
|
|
690
|
+
|`rice_mean(vr, sigma_r)` |Rice analytical mean (Eq. 4)
|
|
691
|
+
|`compute_wind_derived(vx, vy, sigma_r, use_absolute_vx:)` |Compute all derived wind fields
|
|
692
|
+
|===
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
==== Wind table export
|
|
696
|
+
|
|
697
|
+
The `Atmospheris::Export::Iso5878.generate_wind_table` method augments
|
|
698
|
+
empirical wind observation YAML data with computed derived fields (Vsc,
|
|
699
|
+
percentile bounds):
|
|
700
|
+
|
|
701
|
+
[source,ruby]
|
|
702
|
+
----
|
|
703
|
+
require 'atmospheris'
|
|
704
|
+
|
|
705
|
+
wind_data = YAML.load_file('table1.yaml')
|
|
706
|
+
augmented = Atmospheris::Export::Iso5878.generate_wind_table(wind_data)
|
|
707
|
+
File.write('table1-computed.yaml', YAML.dump(augmented))
|
|
708
|
+
----
|
|
709
|
+
|
|
710
|
+
Empirical fields (Vx, Vy, sigma-r, Vsa, nu-max) are never overwritten.
|
|
711
|
+
Only blank computed fields (Vsc, percentile bounds) are filled.
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
==== Data structures
|
|
715
|
+
|
|
716
|
+
`PercentilePair` — Struct with `low` and `high` fields (keyword init):
|
|
717
|
+
|
|
718
|
+
[source,ruby]
|
|
719
|
+
----
|
|
720
|
+
pair = Atmospheris::Iso5878::PercentilePair.new(low: 0.5, high: 14.7)
|
|
721
|
+
pair.low #=> 0.5
|
|
722
|
+
pair.high #=> 14.7
|
|
723
|
+
----
|
|
724
|
+
|
|
725
|
+
`WindDerivedFields` — Struct with `vr`, `sigma`, `vsc`, `percentiles` (keyword init):
|
|
726
|
+
|
|
727
|
+
[source,ruby]
|
|
728
|
+
----
|
|
729
|
+
fields = Atmospheris::Iso5878::WindDerivedFields.new(
|
|
730
|
+
vr: 4.08,
|
|
731
|
+
sigma: 4.17,
|
|
732
|
+
vsc: 6.03,
|
|
733
|
+
percentiles: { 1 => pair, 10 => pair2, 20 => pair3 }
|
|
734
|
+
)
|
|
735
|
+
----
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
=== ISO 5878 — Reference atmosphere profiles
|
|
739
|
+
|
|
740
|
+
ISO 5878 defines reference atmospheres as temperature layer structures for
|
|
741
|
+
selected latitudes (15°, 30°, 45°, 60°, 80°) and seasons, anchored to
|
|
742
|
+
latitude-specific surface conditions. The library models these with
|
|
743
|
+
`SurfaceParameters`, `AtmosphereProfile` and `AtmosphereModelRegistry`.
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
==== SurfaceParameters
|
|
747
|
+
|
|
748
|
+
Latitude-dependent constants per ISO 5878 Eq. 0 (Lambert's equation for
|
|
749
|
+
sea-level gravity), Eq. 13 (nominal earth radius), Eq. 7–9 (altitude
|
|
750
|
+
conversions using latitude-specific gravity and radius):
|
|
751
|
+
|
|
752
|
+
[source,ruby]
|
|
753
|
+
----
|
|
754
|
+
require 'atmospheris'
|
|
755
|
+
|
|
756
|
+
sp = Atmospheris::Iso5878::SurfaceParameters.new(45)
|
|
757
|
+
|
|
758
|
+
sp.gravity_at_sea_level #=> 9.80616 (m/s^2)
|
|
759
|
+
sp.nominal_earth_radius #=> 6356364 (m)
|
|
760
|
+
sp.geopotential_from_geometric(5000) #=> ~4995.8 (m)
|
|
761
|
+
sp.gravity_at_geometric(10_000) #=> 9.7754 (m/s^2)
|
|
762
|
+
----
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
==== AtmosphereProfile
|
|
766
|
+
|
|
767
|
+
An `AtmosphereProfile` is an `Isa::Algorithms` subclass whose constants and
|
|
768
|
+
temperature layers are injected instead of using the ISO 2533 defaults:
|
|
769
|
+
|
|
770
|
+
[source,ruby]
|
|
771
|
+
----
|
|
772
|
+
require 'atmospheris'
|
|
773
|
+
|
|
774
|
+
sp = Atmospheris::Iso5878::SurfaceParameters.new(45)
|
|
775
|
+
|
|
776
|
+
layers = Atmospheris::Iso5878::TemperatureLayerStructure.from_yaml_rows([
|
|
777
|
+
{ geopotential_altitude: 0.0, temperature_K: 272.65 },
|
|
778
|
+
{ geopotential_altitude: 16_500, temperature_K: 216.65 },
|
|
779
|
+
# ...
|
|
780
|
+
]).to_a
|
|
781
|
+
|
|
782
|
+
profile = Atmospheris::Iso5878::AtmosphereProfile.new(
|
|
783
|
+
surface_params: sp,
|
|
784
|
+
surface_temperature: 272.65,
|
|
785
|
+
surface_pressure: 101_800.0,
|
|
786
|
+
layers: layers
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
profile.temperature_at_layer_from_geopotential(5000)
|
|
790
|
+
profile.pressure_from_geopotential(5000)
|
|
791
|
+
profile.density_from_geopotential(5000)
|
|
792
|
+
----
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
==== AtmosphereModelRegistry
|
|
796
|
+
|
|
797
|
+
The registry maps model IDs to latitude, season and surface conditions
|
|
798
|
+
(ISO 5878 Table 2), and builds profiles from Table 16/19 layer data.
|
|
799
|
+
Available model IDs:
|
|
800
|
+
|
|
801
|
+
`15-annual`,
|
|
802
|
+
`30-winter`, `30-summer`,
|
|
803
|
+
`45-winter`, `45-summer`,
|
|
804
|
+
`60-winter`, `60-summer`, `60-warm`, `60-cold`,
|
|
805
|
+
`80-winter`, `80-summer`, `80-warm`, `80-cold`.
|
|
806
|
+
|
|
807
|
+
[source,ruby]
|
|
808
|
+
----
|
|
809
|
+
require 'atmospheris'
|
|
810
|
+
require 'yaml'
|
|
811
|
+
|
|
812
|
+
table16 = YAML.load_file('table16.yaml')
|
|
813
|
+
|
|
814
|
+
profile = Atmospheris::Iso5878::AtmosphereModelRegistry.create("45-winter", table16)
|
|
815
|
+
|
|
816
|
+
profile.temperature_at_layer_from_geopotential(0) #=> 272.62 (K)
|
|
817
|
+
profile.pressure_from_geopotential(5000) #=> 52999.1 (Pa)
|
|
818
|
+
profile.density_from_geopotential(5000) #=> 0.74254 (kg/m^3)
|
|
819
|
+
|
|
820
|
+
Atmospheris::Iso5878::AtmosphereModelRegistry.model_ids
|
|
821
|
+
#=> ["15-annual", "30-winter", ...]
|
|
822
|
+
----
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
==== Atmosphere profile export
|
|
826
|
+
|
|
827
|
+
The `Atmospheris::Export::Iso5878.generate_atmosphere_profile` method
|
|
828
|
+
generates ISO 5878 atmosphere table YAML data for a given model:
|
|
829
|
+
|
|
830
|
+
[source,ruby]
|
|
831
|
+
----
|
|
832
|
+
require 'atmospheris'
|
|
833
|
+
require 'yaml'
|
|
834
|
+
|
|
835
|
+
table16 = YAML.load_file('table16.yaml')
|
|
836
|
+
|
|
837
|
+
data = Atmospheris::Export::Iso5878.generate_atmosphere_profile(
|
|
838
|
+
"45-winter", table16,
|
|
839
|
+
table_id: "atmosphere-table-5",
|
|
840
|
+
title_en: "January reference atmosphere, 45N",
|
|
841
|
+
geometric_altitudes: [0, 5000, 10_000, 20_000]
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
data["rows-h"][0]
|
|
845
|
+
#=> {"geometrical-altitude"=>0, "geopotential-altitude"=>0,
|
|
846
|
+
# "temperature-K"=>272.62, "temperature-C"=>-0.53,
|
|
847
|
+
# "p-mbar"=>1018.0, "density"=>1.300853}
|
|
848
|
+
----
|
|
849
|
+
|
|
580
850
|
|
|
581
851
|
|
|
582
852
|
== Generating ISO 2533 tables
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atmospheris
|
|
4
|
+
module Export
|
|
5
|
+
module Iso5878
|
|
6
|
+
# Generates YAML data matching the existing table3.yaml–table15.yaml format.
|
|
7
|
+
#
|
|
8
|
+
# Computes temperature, pressure, and density at specified geometric altitudes
|
|
9
|
+
# using an AtmosphereProfile instance.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# profile = AtmosphereModelRegistry.create("15-annual", table16_data)
|
|
13
|
+
# export = AtmosphereProfileExport.new(
|
|
14
|
+
# profile,
|
|
15
|
+
# table_id: "atmosphere-table-3",
|
|
16
|
+
# title_en: "Mean annual values ...",
|
|
17
|
+
# geometric_altitudes: (0..10_000).step(1000).to_a + (12_000..80_000).step(2000).to_a
|
|
18
|
+
# )
|
|
19
|
+
# yaml_data = export.generate
|
|
20
|
+
class AtmosphereProfileExport
|
|
21
|
+
STANDARD_ALTITUDES = (
|
|
22
|
+
(0..10_000).step(1000).to_a + (12_000..80_000).step(2000).to_a
|
|
23
|
+
).freeze
|
|
24
|
+
|
|
25
|
+
# @param profile [Iso5878::AtmosphereProfile]
|
|
26
|
+
# @param table_id [String]
|
|
27
|
+
# @param title_en [String]
|
|
28
|
+
# @param geometric_altitudes [Array<Integer>] geometric altitudes in metres
|
|
29
|
+
def initialize(profile, table_id:, title_en:, geometric_altitudes: STANDARD_ALTITUDES)
|
|
30
|
+
@profile = profile
|
|
31
|
+
@table_id = table_id
|
|
32
|
+
@title_en = title_en
|
|
33
|
+
@geometric_altitudes = geometric_altitudes
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash] YAML-serializable atmosphere profile table data
|
|
37
|
+
def generate
|
|
38
|
+
{
|
|
39
|
+
"id" => @table_id,
|
|
40
|
+
"title-en" => @title_en,
|
|
41
|
+
"title-fr" => "",
|
|
42
|
+
"title-ru" => "",
|
|
43
|
+
"note-en" => "",
|
|
44
|
+
"note-fr" => "",
|
|
45
|
+
"note-ru" => "",
|
|
46
|
+
"rows-h" => @geometric_altitudes.map { |h_m| compute_row(h_m) }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def compute_row(h_m)
|
|
53
|
+
h_m_f = h_m.to_f
|
|
54
|
+
gp_h = @profile.geopotential_altitude_from_geometric(h_m_f)
|
|
55
|
+
t_k = @profile.temperature_at_layer_from_geopotential(gp_h)
|
|
56
|
+
t_c = t_k - 273.15
|
|
57
|
+
p_mbar = @profile.pressure_from_geopotential_mbar(gp_h)
|
|
58
|
+
rho = @profile.density_from_geopotential(gp_h)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"geometrical-altitude" => h_m,
|
|
62
|
+
"geopotential-altitude" => gp_h.round,
|
|
63
|
+
"temperature-K" => round3(t_k),
|
|
64
|
+
"temperature-C" => round2(t_c),
|
|
65
|
+
"p-mbar" => scientific_notation(p_mbar),
|
|
66
|
+
"density" => scientific_notation(rho)
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def round2(v)
|
|
71
|
+
(v * 100).round / 100.0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def round3(v)
|
|
75
|
+
(v * 1000).round / 1000.0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Format a float in scientific notation matching YAML convention.
|
|
79
|
+
# e.g. 1013.25 → "1.013250e3", 0.001779 → "1.177987e0"
|
|
80
|
+
def scientific_notation(v)
|
|
81
|
+
v.round(6)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atmospheris
|
|
4
|
+
module Export
|
|
5
|
+
module Iso5878
|
|
6
|
+
# Takes the empirical wind YAML data (Table 1), computes derived fields
|
|
7
|
+
# using WindObservation, and returns augmented YAML with calculated
|
|
8
|
+
# columns filled in where they are nil.
|
|
9
|
+
#
|
|
10
|
+
# Empirical fields (Vx, Vy, sigma-r, Vsa, nu-max) are never overwritten.
|
|
11
|
+
# Only blank computed fields (Vsc, percentile bounds) are filled.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# wind_data = YAML.load_file('04-yaml/table1.yaml')
|
|
15
|
+
# augmented = WindTableExport.new(wind_data).generate
|
|
16
|
+
# File.write('04-yaml/table1-computed.yaml', YAML.dump(augmented))
|
|
17
|
+
class WindTableExport
|
|
18
|
+
# @param wind_yaml_data [Hash] loaded from table1.yaml
|
|
19
|
+
def initialize(wind_yaml_data)
|
|
20
|
+
@source = wind_yaml_data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Hash] augmented YAML data with computed fields filled in
|
|
24
|
+
def generate
|
|
25
|
+
result = @source.dup
|
|
26
|
+
result["rows"] = result["rows"].map do |zone_row|
|
|
27
|
+
augment_zone(zone_row)
|
|
28
|
+
end
|
|
29
|
+
result
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def augment_zone(zone_row)
|
|
35
|
+
zone_row.dup.tap do |row|
|
|
36
|
+
row["values"] = row["values"].map do |obs_row|
|
|
37
|
+
augment_observation(obs_row, zone_row)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def augment_observation(obs, zone_row)
|
|
43
|
+
vx = obs["Vx"]
|
|
44
|
+
sigma_r = obs["sigma-r"]
|
|
45
|
+
return obs.dup if vx.nil? || sigma_r.nil?
|
|
46
|
+
|
|
47
|
+
sigma_r_f = sigma_r.to_f
|
|
48
|
+
return obs.dup if sigma_r_f <= 0
|
|
49
|
+
|
|
50
|
+
use_abs_vx = zone_row["angle-low"].to_i >= 20
|
|
51
|
+
wind = ::Atmospheris::Iso5878::WindObservation.new(
|
|
52
|
+
geopotential_altitude: obs["geopotential-altitude"],
|
|
53
|
+
vx: vx.to_f,
|
|
54
|
+
vy: (obs["Vy"] || 0).to_f,
|
|
55
|
+
sigma_r: sigma_r_f,
|
|
56
|
+
use_absolute_vx: use_abs_vx
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
obs.dup.tap do |o|
|
|
60
|
+
o["Vsc"] = round1(wind.vsc) if obs["Vsc"].nil?
|
|
61
|
+
|
|
62
|
+
bounds = wind.percentile_bounds
|
|
63
|
+
o["Vsc-1-low"] = round1(bounds[1].low) if obs["Vsc-1-low"].nil?
|
|
64
|
+
o["Vsc-1-high"] = round1(bounds[1].high) if obs["Vsc-1-high"].nil?
|
|
65
|
+
o["Vsc-10-low"] = round1(bounds[10].low) if obs["Vsc-10-low"].nil?
|
|
66
|
+
o["Vsc-10-high"] = round1(bounds[10].high) if obs["Vsc-10-high"].nil?
|
|
67
|
+
o["Vsc-20-low"] = round1(bounds[20].low) if obs["Vsc-20-low"].nil?
|
|
68
|
+
o["Vsc-20-high"] = round1(bounds[20].high) if obs["Vsc-20-high"].nil?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def round1(v)
|
|
73
|
+
(v * 10).round / 10.0
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atmospheris
|
|
4
|
+
module Export
|
|
5
|
+
module Iso5878
|
|
6
|
+
autoload :AtmosphereProfileExport,
|
|
7
|
+
"atmospheris/export/iso_5878/atmosphere_profile_export"
|
|
8
|
+
autoload :WindTableExport,
|
|
9
|
+
"atmospheris/export/iso_5878/wind_table_export"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Generate atmosphere profile YAML data for a given model.
|
|
13
|
+
#
|
|
14
|
+
# @param model_id [String] e.g. "15-annual", "60-warm"
|
|
15
|
+
# @param layers_data [Hash] YAML data from table16.yaml or table19.yaml
|
|
16
|
+
# @param table_id [String] YAML id field
|
|
17
|
+
# @param title_en [String] YAML title-en field
|
|
18
|
+
# @param geometric_altitudes [Array<Integer>] geometric altitudes in metres
|
|
19
|
+
# @return [Hash] YAML-serializable data
|
|
20
|
+
def generate_atmosphere_profile(model_id, layers_data, table_id:, title_en:, geometric_altitudes:)
|
|
21
|
+
profile = ::Atmospheris::Iso5878::AtmosphereModelRegistry.create(model_id, layers_data)
|
|
22
|
+
AtmosphereProfileExport.new(
|
|
23
|
+
profile,
|
|
24
|
+
table_id: table_id,
|
|
25
|
+
title_en: title_en,
|
|
26
|
+
geometric_altitudes: geometric_altitudes
|
|
27
|
+
).generate
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Augment wind observation YAML data with computed derived fields.
|
|
31
|
+
#
|
|
32
|
+
# @param wind_yaml_data [Hash] loaded from table1.yaml
|
|
33
|
+
# @return [Hash] augmented data (computed fields filled where nil)
|
|
34
|
+
def generate_wind_table(wind_yaml_data)
|
|
35
|
+
WindTableExport.new(wind_yaml_data).generate
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/atmospheris/export.rb
CHANGED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "atmospheris/isa"
|
|
4
|
+
|
|
5
|
+
module Atmospheris
|
|
6
|
+
module Iso5878
|
|
7
|
+
# Generalized ISA engine for ISO 5878 reference atmospheres.
|
|
8
|
+
#
|
|
9
|
+
# Extends Isa::Algorithms (open/closed principle) by injecting custom
|
|
10
|
+
# temperature layer structures and latitude-dependent surface conditions.
|
|
11
|
+
# All barometric formula methods (pressure, density, etc.) are inherited
|
|
12
|
+
# and automatically use the custom configuration.
|
|
13
|
+
#
|
|
14
|
+
# One AtmosphereProfile instance = one atmosphere model (e.g. "15 annual"
|
|
15
|
+
# or "60N winter warm regime").
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# params = SurfaceParameters.new(15)
|
|
19
|
+
# layers = Iso5878::TemperatureLayerStructure.from_yaml_rows(yaml_rows)
|
|
20
|
+
# profile = AtmosphereProfile.new(
|
|
21
|
+
# surface_params: params,
|
|
22
|
+
# surface_temperature: 299.65,
|
|
23
|
+
# surface_pressure: 101325.0,
|
|
24
|
+
# layers: layers.to_a
|
|
25
|
+
# )
|
|
26
|
+
# profile.pressure_from_geopotential_mbar(5000)
|
|
27
|
+
class AtmosphereProfile < Isa::Algorithms
|
|
28
|
+
R_SPECIFIC = 287.05287
|
|
29
|
+
|
|
30
|
+
attr_reader :surface_params, :surface_temperature, :surface_pressure, :model_layers
|
|
31
|
+
|
|
32
|
+
# @param surface_params [SurfaceParameters]
|
|
33
|
+
# @param surface_temperature [Numeric] T at sea level (K)
|
|
34
|
+
# @param surface_pressure [Numeric] P at sea level (Pa)
|
|
35
|
+
# @param layers [Array<Hash>] ISA-format temperature layers
|
|
36
|
+
# [{ H: 0.0, T: 299.65, B: -0.006 }, ...]
|
|
37
|
+
# H in metres, T in Kelvin, B in K/m. Last layer has no :B key.
|
|
38
|
+
def initialize(surface_params:, surface_temperature:, surface_pressure:, layers:)
|
|
39
|
+
@surface_params = surface_params
|
|
40
|
+
@surface_temperature = surface_temperature.to_f
|
|
41
|
+
@surface_pressure = surface_pressure.to_f
|
|
42
|
+
@model_layers = layers
|
|
43
|
+
set_precision(:normal)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Override: inject latitude-specific constants instead of ISA defaults.
|
|
49
|
+
def make_constants
|
|
50
|
+
g0 = surface_params.gravity_at_sea_level
|
|
51
|
+
radius = surface_params.nominal_earth_radius
|
|
52
|
+
p_n = surface_pressure
|
|
53
|
+
t_n = surface_temperature
|
|
54
|
+
r_star = 8.31432
|
|
55
|
+
rho_n = p_n / (R_SPECIFIC * t_n)
|
|
56
|
+
molar = rho_n * r_star * t_n / p_n
|
|
57
|
+
|
|
58
|
+
@constants = {
|
|
59
|
+
g_n: g0,
|
|
60
|
+
N_A: 6.02257e23,
|
|
61
|
+
p_n: p_n,
|
|
62
|
+
rho_n: rho_n,
|
|
63
|
+
T_n: t_n,
|
|
64
|
+
R_star: r_star,
|
|
65
|
+
radius: radius,
|
|
66
|
+
k: 1.4,
|
|
67
|
+
M: molar,
|
|
68
|
+
R: r_star / molar
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@sqrt2 = Math.sqrt(2)
|
|
72
|
+
@pi = Math::PI
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Override: locate layer index using injected model_layers.
|
|
76
|
+
def locate_lower_layer(geopotential_alt)
|
|
77
|
+
return 0 if geopotential_alt < model_layers[0][:H]
|
|
78
|
+
|
|
79
|
+
i = model_layers.length - 1
|
|
80
|
+
return i - 1 if geopotential_alt >= model_layers[i][:H]
|
|
81
|
+
|
|
82
|
+
model_layers.each_with_index do |layer, ind|
|
|
83
|
+
return ind - 1 if layer[:H] > geopotential_alt
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Override: pressure layer base values computed from injected layers.
|
|
89
|
+
def pressure_layers
|
|
90
|
+
return @pressure_layers if @pressure_layers
|
|
91
|
+
|
|
92
|
+
p = []
|
|
93
|
+
model_layers.each_with_index do |_x, i|
|
|
94
|
+
last_i = i.zero? ? 0 : i - 1
|
|
95
|
+
last_layer = model_layers[last_i]
|
|
96
|
+
beta = last_layer[:B] || 0.0
|
|
97
|
+
|
|
98
|
+
if last_layer[:H] <= 0
|
|
99
|
+
p_b = @constants[:p_n]
|
|
100
|
+
h_b = 0.0
|
|
101
|
+
t_b = @constants[:T_n]
|
|
102
|
+
else
|
|
103
|
+
p_b = p[last_i]
|
|
104
|
+
h_b = last_layer[:H]
|
|
105
|
+
t_b = last_layer[:T]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
current_layer = model_layers[i]
|
|
109
|
+
h_curr = current_layer[:H]
|
|
110
|
+
dh = h_curr - h_b
|
|
111
|
+
|
|
112
|
+
p[i] = if beta.zero?
|
|
113
|
+
pressure_formula_beta_zero(p_b, current_layer[:T], dh)
|
|
114
|
+
else
|
|
115
|
+
pressure_formula_beta_nonzero(p_b, beta, t_b, dh)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@pressure_layers = p
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- Public method overrides (must remain public) ---
|
|
123
|
+
|
|
124
|
+
# Override: temperature lookup uses injected model_layers.
|
|
125
|
+
def temperature_at_layer_from_geopotential(geopotential_alt)
|
|
126
|
+
idx = locate_lower_layer(geopotential_alt)
|
|
127
|
+
layer = model_layers[idx]
|
|
128
|
+
beta = layer[:B] || 0.0
|
|
129
|
+
t_b = layer[:T]
|
|
130
|
+
h_b = layer[:H]
|
|
131
|
+
t_b + beta * (geopotential_alt - h_b)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Override: pressure at altitude uses injected model_layers.
|
|
135
|
+
def pressure_from_geopotential(geopotential_alt)
|
|
136
|
+
i = locate_lower_layer(geopotential_alt)
|
|
137
|
+
layer = model_layers[i]
|
|
138
|
+
beta = layer[:B] || 0.0
|
|
139
|
+
h_b = layer[:H]
|
|
140
|
+
t_b = layer[:T]
|
|
141
|
+
temp = temperature_at_layer_from_geopotential(geopotential_alt)
|
|
142
|
+
p_b = pressure_layers[i]
|
|
143
|
+
dh = geopotential_alt - h_b
|
|
144
|
+
|
|
145
|
+
if beta.zero?
|
|
146
|
+
pressure_formula_beta_zero(p_b, temp, dh)
|
|
147
|
+
else
|
|
148
|
+
pressure_formula_beta_nonzero(p_b, beta, t_b, dh)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
public :temperature_at_layer_from_geopotential, :pressure_from_geopotential
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Converts YAML breakpoint data (Table 16 / Table 19 format) into
|
|
156
|
+
# the ISA-compatible layer format used by AtmosphereProfile.
|
|
157
|
+
#
|
|
158
|
+
# Input format (YAML rows):
|
|
159
|
+
# [{ geopotential_altitude: 0.0, temperature_K: 299.65 },
|
|
160
|
+
# { geopotential_altitude: 2.25, temperature_K: 286.15 },
|
|
161
|
+
# ...]
|
|
162
|
+
#
|
|
163
|
+
# Output format (ISA layers):
|
|
164
|
+
# [{ H: 0.0, T: 299.65, B: -0.006 },
|
|
165
|
+
# { H: 2250.0, T: 286.15, B: 0.0032 },
|
|
166
|
+
# ...]
|
|
167
|
+
#
|
|
168
|
+
# Gradients (B) are computed from consecutive temperature/altitude pairs
|
|
169
|
+
# rather than read from the YAML gradient column, ensuring consistency
|
|
170
|
+
# with the specified temperature breakpoints.
|
|
171
|
+
class TemperatureLayerStructure
|
|
172
|
+
attr_reader :layers
|
|
173
|
+
|
|
174
|
+
# @param yaml_rows [Array<Hash>] breakpoint data from table16/19 YAML
|
|
175
|
+
# Keys: :geopotential_altitude (km), :temperature_K (K)
|
|
176
|
+
def initialize(yaml_rows)
|
|
177
|
+
@layers = build_layers(yaml_rows)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Construct from a YAML row subset (e.g., yaml_data["rows-15"])
|
|
181
|
+
# @param rows [Array<Hash>]
|
|
182
|
+
# @return [TemperatureLayerStructure]
|
|
183
|
+
def self.from_yaml_rows(rows)
|
|
184
|
+
new(rows)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @return [Array<Hash>] ISA-format layers
|
|
188
|
+
def to_a
|
|
189
|
+
@layers
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def build_layers(rows)
|
|
195
|
+
layers = []
|
|
196
|
+
rows.each_with_index do |row, i|
|
|
197
|
+
h_m = row[:geopotential_altitude].to_f * 1000.0 # km -> m
|
|
198
|
+
t_k = row[:temperature_K].to_f
|
|
199
|
+
|
|
200
|
+
if i < rows.length - 1
|
|
201
|
+
next_row = rows[i + 1]
|
|
202
|
+
next_h = next_row[:geopotential_altitude].to_f * 1000.0
|
|
203
|
+
next_t = next_row[:temperature_K].to_f
|
|
204
|
+
dh = next_h - h_m
|
|
205
|
+
beta = dh.abs < 1e-12 ? 0.0 : (next_t - t_k) / dh # K/m
|
|
206
|
+
layers << { H: h_m, T: t_k, B: beta }
|
|
207
|
+
else
|
|
208
|
+
# Last breakpoint — no layer above it
|
|
209
|
+
layers << { H: h_m, T: t_k }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
layers
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atmospheris
|
|
4
|
+
module Iso5878
|
|
5
|
+
# Factory for constructing AtmosphereProfile instances from model identifiers.
|
|
6
|
+
#
|
|
7
|
+
# Maps model IDs (e.g. "15-annual", "60-warm") to their latitude, season,
|
|
8
|
+
# and temperature layer structure key. Surface conditions are looked up
|
|
9
|
+
# from Table 2 data. Layer structures are loaded from YAML data (Table 16/19).
|
|
10
|
+
#
|
|
11
|
+
# Open/closed: new models can be added to MODELS without modifying
|
|
12
|
+
# AtmosphereProfile or TemperatureLayerStructure.
|
|
13
|
+
class AtmosphereModelRegistry
|
|
14
|
+
# Model definitions: maps model_id to configuration
|
|
15
|
+
MODELS = {
|
|
16
|
+
"15-annual" => { latitude: 15, season: :annual, layers_key: :"rows-15" },
|
|
17
|
+
"30-winter" => { latitude: 30, season: :winter, layers_key: :"rows-30-w" },
|
|
18
|
+
"30-summer" => { latitude: 30, season: :summer, layers_key: :"rows-30-s" },
|
|
19
|
+
"45-winter" => { latitude: 45, season: :winter, layers_key: :"rows-45-w" },
|
|
20
|
+
"45-summer" => { latitude: 45, season: :summer, layers_key: :"rows-45-s" },
|
|
21
|
+
"60-winter" => { latitude: 60, season: :winter, layers_key: :"rows-60-w" },
|
|
22
|
+
"60-summer" => { latitude: 60, season: :summer, layers_key: :"rows-60-s" },
|
|
23
|
+
"60-warm" => { latitude: 60, season: :winter, layers_key: :"rows-60-warm" },
|
|
24
|
+
"60-cold" => { latitude: 60, season: :winter, layers_key: :"rows-60-cold" },
|
|
25
|
+
"80-winter" => { latitude: 80, season: :winter, layers_key: :"rows-80-w" },
|
|
26
|
+
"80-summer" => { latitude: 80, season: :summer, layers_key: :"rows-80-s" },
|
|
27
|
+
"80-warm" => { latitude: 80, season: :winter, layers_key: :"rows-80-warm" },
|
|
28
|
+
"80-cold" => { latitude: 80, season: :winter, layers_key: :"rows-80-cold" }
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Surface conditions from ISO 5878 Table 2.
|
|
32
|
+
# Pressures in Pa, temperatures in K.
|
|
33
|
+
SURFACE_CONDITIONS = {
|
|
34
|
+
15 => {
|
|
35
|
+
annual: { T: 299.650, P: 101_325.0 }
|
|
36
|
+
},
|
|
37
|
+
30 => {
|
|
38
|
+
winter: { T: 283.150, P: 102_050.0 },
|
|
39
|
+
summer: { T: 297.150, P: 101_400.0 }
|
|
40
|
+
},
|
|
41
|
+
45 => {
|
|
42
|
+
winter: { T: 272.650, P: 101_800.0 },
|
|
43
|
+
summer: { T: 291.150, P: 101_350.0 }
|
|
44
|
+
},
|
|
45
|
+
60 => {
|
|
46
|
+
winter: { T: 256.150, P: 101_300.0 },
|
|
47
|
+
summer: { T: 282.150, P: 101_020.0 }
|
|
48
|
+
},
|
|
49
|
+
80 => {
|
|
50
|
+
winter: { T: 248.950, P: 101_380.0 },
|
|
51
|
+
summer: { T: 276.650, P: 101_200.0 }
|
|
52
|
+
}
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# Construct an AtmosphereProfile for the given model ID.
|
|
56
|
+
#
|
|
57
|
+
# @param model_id [String] e.g. "15-annual", "60-warm"
|
|
58
|
+
# @param layers_data [Hash] YAML data from table16.yaml or table19.yaml
|
|
59
|
+
# Must contain the key matching the model's layers_key
|
|
60
|
+
# @return [AtmosphereProfile]
|
|
61
|
+
def self.create(model_id, layers_data)
|
|
62
|
+
config = MODELS.fetch(model_id) do
|
|
63
|
+
raise ArgumentError, "Unknown ISO 5878 model: #{model_id}. " \
|
|
64
|
+
"Available: #{MODELS.keys.join(", ")}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
surface = SURFACE_CONDITIONS.dig(config[:latitude], config[:season])
|
|
68
|
+
raise ArgumentError, "No surface conditions for #{config[:latitude]}° #{config[:season]}" unless surface
|
|
69
|
+
|
|
70
|
+
raw_rows = layers_data[config[:layers_key].to_s]
|
|
71
|
+
raise ArgumentError, "Layer data key '#{config[:layers_key]}' not found in provided data" unless raw_rows
|
|
72
|
+
|
|
73
|
+
# Normalize YAML keys to symbols
|
|
74
|
+
normalized = raw_rows.map do |row|
|
|
75
|
+
{
|
|
76
|
+
geopotential_altitude: row["geopotential-altitude"] || row[:geopotential_altitude],
|
|
77
|
+
temperature_K: row["temperature-K"] || row[:temperature_K]
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
tls = TemperatureLayerStructure.from_yaml_rows(normalized)
|
|
82
|
+
params = SurfaceParameters.new(config[:latitude])
|
|
83
|
+
|
|
84
|
+
AtmosphereProfile.new(
|
|
85
|
+
surface_params: params,
|
|
86
|
+
surface_temperature: surface[:T],
|
|
87
|
+
surface_pressure: surface[:P],
|
|
88
|
+
layers: tls.to_a
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# List all available model IDs.
|
|
93
|
+
# @return [Array<String>]
|
|
94
|
+
def self.model_ids
|
|
95
|
+
MODELS.keys
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atmospheris
|
|
4
|
+
module Iso5878
|
|
5
|
+
# Encapsulates a single Rice (circular normal) distribution instance
|
|
6
|
+
# with fixed parameters (Vr, sigma_r).
|
|
7
|
+
#
|
|
8
|
+
# Wraps the existing module-level methods (bessel_i0, rice_pdf, etc.)
|
|
9
|
+
# in an object-oriented interface, enabling lazy caching and clean
|
|
10
|
+
# composition by WindObservation.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# dist = RiceDistribution.new(vr: 3.9, sigma_r: 5.9)
|
|
14
|
+
# dist.mean # => 6.03
|
|
15
|
+
# dist.quantile(0.99) # => 14.7
|
|
16
|
+
# dist.percentile_bounds
|
|
17
|
+
class RiceDistribution
|
|
18
|
+
attr_reader :vr, :sigma_r, :sigma
|
|
19
|
+
|
|
20
|
+
# @param vr [Numeric] magnitude of vector mean wind (m/s)
|
|
21
|
+
# @param sigma_r [Numeric] standard deviation of vector mean wind (m/s)
|
|
22
|
+
def initialize(vr:, sigma_r:)
|
|
23
|
+
@vr = vr.to_f
|
|
24
|
+
@sigma_r = sigma_r.to_f
|
|
25
|
+
@sigma = @sigma_r / Math.sqrt(2)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Probability density at wind speed nu.
|
|
29
|
+
# @param nu [Numeric] wind speed (m/s)
|
|
30
|
+
# @return [Float]
|
|
31
|
+
def pdf(nu)
|
|
32
|
+
Iso5878.rice_pdf(nu, vr, sigma_r)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Cumulative distribution function at wind speed x.
|
|
36
|
+
# @param x [Numeric] wind speed (m/s)
|
|
37
|
+
# @return [Float] probability in [0, 1]
|
|
38
|
+
def cdf(x)
|
|
39
|
+
Iso5878.rice_cdf(x, vr, sigma_r)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Inverse CDF (quantile function) for probability p.
|
|
43
|
+
# @param p [Numeric] probability in (0, 1)
|
|
44
|
+
# @return [Float] wind speed (m/s)
|
|
45
|
+
def quantile(p)
|
|
46
|
+
Iso5878.rice_inv_cdf(p, vr, sigma_r)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Analytical mean of the Rice distribution (Eq. 4).
|
|
50
|
+
# This is the calculated scalar mean wind speed Vsc.
|
|
51
|
+
# @return [Float] mean wind speed (m/s)
|
|
52
|
+
def mean
|
|
53
|
+
@mean ||= Iso5878.rice_mean(vr, sigma_r)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Percentile bounds as defined in ISO 5878.
|
|
57
|
+
# Returns hash mapping percentage to PercentilePair (low/high).
|
|
58
|
+
# @return [Hash{Integer => PercentilePair}]
|
|
59
|
+
def percentile_bounds
|
|
60
|
+
@percentile_bounds ||= {
|
|
61
|
+
1 => PercentilePair.new(
|
|
62
|
+
low: quantile(0.01),
|
|
63
|
+
high: quantile(0.99)
|
|
64
|
+
),
|
|
65
|
+
10 => PercentilePair.new(
|
|
66
|
+
low: quantile(0.10),
|
|
67
|
+
high: quantile(0.90)
|
|
68
|
+
),
|
|
69
|
+
20 => PercentilePair.new(
|
|
70
|
+
low: quantile(0.20),
|
|
71
|
+
high: quantile(0.80)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atmospheris
|
|
4
|
+
module Iso5878
|
|
5
|
+
# Encapsulates latitude-dependent surface parameters for ISO 5878
|
|
6
|
+
# reference atmospheres.
|
|
7
|
+
#
|
|
8
|
+
# Computes gravity at sea level (Lambert's equation, Eq. 0) and nominal
|
|
9
|
+
# earth radius (Eq. 13) from geographic latitude. Provides altitude
|
|
10
|
+
# conversion methods that use latitude-specific gravity and radius.
|
|
11
|
+
#
|
|
12
|
+
# Immutable value object — all derived values are computed from latitude.
|
|
13
|
+
class SurfaceParameters
|
|
14
|
+
G_N = 9.80665 # standard gravity (m/s^2), ISO 2533
|
|
15
|
+
R_SPECIFIC = 287.05287 # specific gas constant (J/(kg*K))
|
|
16
|
+
|
|
17
|
+
attr_reader :latitude_deg
|
|
18
|
+
|
|
19
|
+
# @param latitude_deg [Numeric] geographic latitude in degrees
|
|
20
|
+
def initialize(latitude_deg)
|
|
21
|
+
@latitude_deg = latitude_deg.to_f
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Eq. 0 — Lambert's equation for acceleration of free fall at sea level.
|
|
25
|
+
# @return [Float] g_0(phi) in m/s^2
|
|
26
|
+
def gravity_at_sea_level
|
|
27
|
+
phi_rad = @latitude_deg * Math::PI / 180.0
|
|
28
|
+
cos2phi = Math.cos(2.0 * phi_rad)
|
|
29
|
+
9.80616 * (1.0 - 0.0026373 * cos2phi + 0.0000059 * cos2phi * cos2phi)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Eq. 13 — Nominal earth radius at the given latitude.
|
|
33
|
+
# @return [Float] r_phi in metres
|
|
34
|
+
def nominal_earth_radius
|
|
35
|
+
phi_rad = @latitude_deg * Math::PI / 180.0
|
|
36
|
+
cos2phi = Math.cos(2.0 * phi_rad)
|
|
37
|
+
g0 = gravity_at_sea_level
|
|
38
|
+
g0 * 2.0 / (3.085462e-6 + 2.27e-9 * cos2phi)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Eq. 7 — Acceleration of free fall at geometric altitude h.
|
|
42
|
+
# @param h_m [Numeric] geometric altitude in metres
|
|
43
|
+
# @return [Float] g_phi(h) in m/s^2
|
|
44
|
+
def gravity_at_geometric(h_m)
|
|
45
|
+
r = nominal_earth_radius
|
|
46
|
+
ratio = r / (r + h_m)
|
|
47
|
+
gravity_at_sea_level * ratio * ratio
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Eq. 8 — Geopotential altitude from geometric altitude.
|
|
51
|
+
# @param h_m [Numeric] geometric altitude in metres
|
|
52
|
+
# @return [Float] geopotential altitude in metres
|
|
53
|
+
def geopotential_from_geometric(h_m)
|
|
54
|
+
r = nominal_earth_radius
|
|
55
|
+
g0 = gravity_at_sea_level
|
|
56
|
+
(r * h_m / (r + h_m)) * (g0 / G_N)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Eq. 9 — Geometric altitude from geopotential altitude.
|
|
60
|
+
# @param gp_m [Numeric] geopotential altitude in metres
|
|
61
|
+
# @return [Float] geometric altitude in metres
|
|
62
|
+
def geometric_from_geopotential(gp_m)
|
|
63
|
+
r = nominal_earth_radius
|
|
64
|
+
g0 = gravity_at_sea_level
|
|
65
|
+
(r * gp_m) / ((g0 / G_N) * r - gp_m)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atmospheris
|
|
4
|
+
module Iso5878
|
|
5
|
+
# Encapsulates a single altitude-level wind observation with its
|
|
6
|
+
# empirically measured parameters and lazily computed derived statistics.
|
|
7
|
+
#
|
|
8
|
+
# Empirical inputs: Vx, Vy, sigma_r, Vsa (optional), nu_max (optional)
|
|
9
|
+
# Derived outputs: Vr, theta, Vsc, percentile bounds
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# obs = WindObservation.new(
|
|
13
|
+
# geopotential_altitude: 1000,
|
|
14
|
+
# vx: -3.9, vy: -1.2, sigma_r: 5.9,
|
|
15
|
+
# vsa: 7.6
|
|
16
|
+
# )
|
|
17
|
+
# obs.vr # => 4.08
|
|
18
|
+
# obs.vsc # => 6.03 (calculated)
|
|
19
|
+
# obs.distribution.percentile_bounds[1].high # => 14.7
|
|
20
|
+
class WindObservation
|
|
21
|
+
attr_reader :geopotential_altitude, :vx, :vy, :sigma_r, :vsa, :nu_max
|
|
22
|
+
|
|
23
|
+
# @param geopotential_altitude [Numeric] altitude in metres
|
|
24
|
+
# @param vx [Numeric] mean zonal wind component (m/s)
|
|
25
|
+
# @param vy [Numeric] mean meridional wind component (m/s)
|
|
26
|
+
# @param sigma_r [Numeric] standard deviation of vector mean wind (m/s)
|
|
27
|
+
# @param vsa [Numeric, nil] observed scalar mean speed (m/s)
|
|
28
|
+
# @param nu_max [Numeric, nil] max observed speed once in 10 years (m/s)
|
|
29
|
+
# @param use_absolute_vx [Boolean] for zones > 20°N where Vy ≈ 0
|
|
30
|
+
def initialize(geopotential_altitude:, vx:, vy:, sigma_r:, vsa: nil, nu_max: nil, use_absolute_vx: false)
|
|
31
|
+
@geopotential_altitude = geopotential_altitude.to_f
|
|
32
|
+
@vx = vx.to_f
|
|
33
|
+
@vy = vy.to_f
|
|
34
|
+
@sigma_r = sigma_r.to_f
|
|
35
|
+
@vsa = vsa
|
|
36
|
+
@nu_max = nu_max
|
|
37
|
+
@use_absolute_vx = use_absolute_vx
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Magnitude of the vector mean wind.
|
|
41
|
+
# For zones > 20°N, uses |Vx| as specified in ISO 5878 Section 5.4.
|
|
42
|
+
# @return [Float]
|
|
43
|
+
def vr
|
|
44
|
+
@vr ||= @use_absolute_vx ? @vx.abs : Math.sqrt(@vx**2 + @vy**2)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Direction of the vector mean wind (radians from east).
|
|
48
|
+
# @return [Float]
|
|
49
|
+
def theta
|
|
50
|
+
@theta ||= Math.atan2(@vy, @vx)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The Rice distribution for this observation level.
|
|
54
|
+
# Lazily constructed and cached.
|
|
55
|
+
# @return [RiceDistribution]
|
|
56
|
+
def distribution
|
|
57
|
+
@distribution ||= RiceDistribution.new(vr: vr, sigma_r: @sigma_r)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Calculated scalar mean wind speed (Vsc).
|
|
61
|
+
# @return [Float]
|
|
62
|
+
def vsc
|
|
63
|
+
@vsc ||= distribution.mean
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Calculated percentile bounds.
|
|
67
|
+
# @return [Hash{Integer => PercentilePair}]
|
|
68
|
+
def percentile_bounds
|
|
69
|
+
@percentile_bounds ||= distribution.percentile_bounds
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Full derived result as a WindDerivedFields struct (legacy API compat).
|
|
73
|
+
# @return [WindDerivedFields]
|
|
74
|
+
def derived_fields
|
|
75
|
+
@derived_fields ||= WindDerivedFields.new(
|
|
76
|
+
vr: vr,
|
|
77
|
+
sigma: distribution.sigma,
|
|
78
|
+
vsc: vsc,
|
|
79
|
+
percentiles: percentile_bounds
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ISO 5878 — Reference atmospheres for aerospace use
|
|
4
|
+
# Wind characteristics: circular normal (Rice) distribution
|
|
5
|
+
# Implements Section 5.4 calculation methods
|
|
6
|
+
|
|
7
|
+
module Atmospheris
|
|
8
|
+
module Iso5878
|
|
9
|
+
autoload :SurfaceParameters, "atmospheris/iso5878/surface_parameters"
|
|
10
|
+
autoload :AtmosphereProfile, "atmospheris/iso5878/atmosphere_profile"
|
|
11
|
+
autoload :TemperatureLayerStructure, "atmospheris/iso5878/atmosphere_profile"
|
|
12
|
+
autoload :AtmosphereModelRegistry, "atmospheris/iso5878/model_registry"
|
|
13
|
+
autoload :RiceDistribution, "atmospheris/iso5878/rice_distribution"
|
|
14
|
+
autoload :WindObservation, "atmospheris/iso5878/wind_observation"
|
|
15
|
+
|
|
16
|
+
# --- Bessel functions (Abramowitz & Stegun polynomial approximations) ---
|
|
17
|
+
# Relative error < 1.6x10^-7
|
|
18
|
+
|
|
19
|
+
# Modified Bessel function of the first kind, order zero (I_0).
|
|
20
|
+
# Polynomial approximation from A&S 9.8.1/9.8.2.
|
|
21
|
+
def self.bessel_i0(x)
|
|
22
|
+
ax = x.abs
|
|
23
|
+
if ax <= 3.75
|
|
24
|
+
y = (x / 3.75)**2
|
|
25
|
+
1.0 + y * (3.5156229 + y * (3.0899424 + y * (1.2067492 +
|
|
26
|
+
y * (0.2659732 + y * (0.0360768 + y * 0.0045813)))))
|
|
27
|
+
else
|
|
28
|
+
y = 3.75 / ax
|
|
29
|
+
(Math.exp(ax) / Math.sqrt(ax)) * (0.39894228 + y * (0.01328592 +
|
|
30
|
+
y * (0.00225319 + y * (-0.00157565 + y * (0.00916281 +
|
|
31
|
+
y * (-0.02057706 + y * (0.02635537 + y * (-0.01647633 +
|
|
32
|
+
y * 0.00392377))))))))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Modified Bessel function of the first kind, order one (I_1).
|
|
37
|
+
# Polynomial approximation from A&S 9.8.3/9.8.4.
|
|
38
|
+
def self.bessel_i1(x)
|
|
39
|
+
ax = x.abs
|
|
40
|
+
if ax <= 3.75
|
|
41
|
+
y = (x / 3.75)**2
|
|
42
|
+
value = ax * (0.5 + y * (0.87890594 + y * (0.51498869 +
|
|
43
|
+
y * (0.15084934 + y * (0.02658733 + y * (0.00301532 +
|
|
44
|
+
y * 0.00032411))))))
|
|
45
|
+
else
|
|
46
|
+
y = 3.75 / ax
|
|
47
|
+
value = (Math.exp(ax) / Math.sqrt(ax)) * (0.39894228 + y * (-0.03988024 +
|
|
48
|
+
y * (-0.00362018 + y * (0.00163801 + y * (-0.01031555 +
|
|
49
|
+
y * (0.02282967 + y * (-0.02895312 + y * (0.01787654 +
|
|
50
|
+
y * -0.00420059))))))))
|
|
51
|
+
end
|
|
52
|
+
x.negative? ? -value : value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- Rice (circular normal) distribution ---
|
|
56
|
+
# ISO 5878 Eq. 3 PDF:
|
|
57
|
+
# f(v) = (2v/sigma_r^2) exp(-(v^2 + V_r^2)/sigma_r^2) I_0(2vV_r/sigma_r^2)
|
|
58
|
+
|
|
59
|
+
# Rice distribution PDF per ISO 5878 Eq. 3.
|
|
60
|
+
def self.rice_pdf(nu, vr, sigma_r)
|
|
61
|
+
return 0.0 if nu <= 0
|
|
62
|
+
|
|
63
|
+
sr2 = sigma_r * sigma_r
|
|
64
|
+
ratio = 2.0 * nu * vr / sr2
|
|
65
|
+
(2.0 * nu / sr2) * Math.exp(-(nu * nu + vr * vr) / sr2) * bessel_i0(ratio)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Rice distribution CDF via adaptive Simpson quadrature on the PDF.
|
|
69
|
+
def self.rice_cdf(x, vr, sigma_r)
|
|
70
|
+
return 0.0 if x <= 0
|
|
71
|
+
|
|
72
|
+
sigma = sigma_r / Math.sqrt(2)
|
|
73
|
+
|
|
74
|
+
# Rayleigh limit for very small Vr
|
|
75
|
+
return 1.0 - Math.exp(-x * x / (2.0 * sigma * sigma)) if vr < sigma * 1e-6
|
|
76
|
+
|
|
77
|
+
adaptive_simpson(
|
|
78
|
+
->(t) { rice_pdf(t, vr, sigma_r) },
|
|
79
|
+
0.0, x, 1e-10, 30
|
|
80
|
+
).clamp(0.0, 1.0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Rice distribution inverse CDF (quantile function) via bisection.
|
|
84
|
+
def self.rice_inv_cdf(p, vr, sigma_r)
|
|
85
|
+
return 0.0 if p <= 0
|
|
86
|
+
return Float::INFINITY if p >= 1
|
|
87
|
+
|
|
88
|
+
sigma = sigma_r / Math.sqrt(2)
|
|
89
|
+
|
|
90
|
+
lo = 0.0
|
|
91
|
+
hi = sigma * Math.sqrt(-2.0 * Math.log(1 - p)) * 3 + vr + 4 * sigma
|
|
92
|
+
|
|
93
|
+
100.times do
|
|
94
|
+
mid = (lo + hi) / 2.0
|
|
95
|
+
cdf = rice_cdf(mid, vr, sigma_r)
|
|
96
|
+
if cdf < p
|
|
97
|
+
lo = mid
|
|
98
|
+
else
|
|
99
|
+
hi = mid
|
|
100
|
+
end
|
|
101
|
+
break if hi - lo < 1e-10
|
|
102
|
+
end
|
|
103
|
+
(lo + hi) / 2.0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Rice distribution mean (scalar wind speed Vsc per ISO 5878 Eq. 4).
|
|
107
|
+
def self.rice_mean(vr, sigma_r)
|
|
108
|
+
sigma = sigma_r / Math.sqrt(2)
|
|
109
|
+
lambda = vr * vr / (4.0 * sigma * sigma)
|
|
110
|
+
prefactor = sigma * Math.sqrt(Math::PI / 2.0) * Math.exp(-lambda)
|
|
111
|
+
b0 = bessel_i0(lambda)
|
|
112
|
+
b1 = bessel_i1(lambda)
|
|
113
|
+
prefactor * ((1.0 + 2.0 * lambda) * b0 + 2.0 * lambda * b1)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# --- Main API (legacy) ---
|
|
117
|
+
|
|
118
|
+
WindDerivedFields = Struct.new(:vr, :sigma, :vsc, :percentiles, keyword_init: true)
|
|
119
|
+
PercentilePair = Struct.new(:low, :high, keyword_init: true)
|
|
120
|
+
|
|
121
|
+
# Compute wind distribution derived fields from observed parameters
|
|
122
|
+
# using the circular normal (Rice) distribution per ISO 5878 Section 5.4.
|
|
123
|
+
#
|
|
124
|
+
# @param vx [Float] Mean zonal component of the wind (m/s)
|
|
125
|
+
# @param vy [Float] Mean meridional component of the wind (m/s)
|
|
126
|
+
# @param sigma_r [Float] Standard deviation of the vector mean wind (m/s)
|
|
127
|
+
# @param use_absolute_vx [Boolean] For zones > 20degN where Vy ~ 0
|
|
128
|
+
def self.compute_wind_derived(vx, vy, sigma_r, use_absolute_vx: false)
|
|
129
|
+
vr = use_absolute_vx ? vx.abs : Math.sqrt(vx * vx + vy * vy)
|
|
130
|
+
sigma = sigma_r / Math.sqrt(2)
|
|
131
|
+
|
|
132
|
+
WindDerivedFields.new(
|
|
133
|
+
vr: vr,
|
|
134
|
+
sigma: sigma,
|
|
135
|
+
vsc: rice_mean(vr, sigma_r),
|
|
136
|
+
percentiles: {
|
|
137
|
+
1 => PercentilePair.new(
|
|
138
|
+
low: rice_inv_cdf(0.01, vr, sigma_r),
|
|
139
|
+
high: rice_inv_cdf(0.99, vr, sigma_r)
|
|
140
|
+
),
|
|
141
|
+
10 => PercentilePair.new(
|
|
142
|
+
low: rice_inv_cdf(0.10, vr, sigma_r),
|
|
143
|
+
high: rice_inv_cdf(0.90, vr, sigma_r)
|
|
144
|
+
),
|
|
145
|
+
20 => PercentilePair.new(
|
|
146
|
+
low: rice_inv_cdf(0.20, vr, sigma_r),
|
|
147
|
+
high: rice_inv_cdf(0.80, vr, sigma_r)
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Adaptive Simpson quadrature (iterative).
|
|
154
|
+
def self.adaptive_simpson(f, a, b, tol, max_depth)
|
|
155
|
+
fa = f.call(a)
|
|
156
|
+
fb = f.call(b)
|
|
157
|
+
m = (a + b) / 2.0
|
|
158
|
+
fm = f.call(m)
|
|
159
|
+
whole = (b - a) / 6.0 * (fa + 4.0 * fm + fb)
|
|
160
|
+
|
|
161
|
+
stack = [[a, b, fa, fb, fm, whole, tol, 0]]
|
|
162
|
+
total = 0.0
|
|
163
|
+
|
|
164
|
+
while (item = stack.pop)
|
|
165
|
+
la, lb, lfa, lfb, lfm, s_whole, l_tol, depth = item
|
|
166
|
+
lm = (la + lb) / 2.0
|
|
167
|
+
h = lb - la
|
|
168
|
+
|
|
169
|
+
lm1 = (la + lm) / 2.0
|
|
170
|
+
lm2 = (lm + lb) / 2.0
|
|
171
|
+
fm1 = f.call(lm1)
|
|
172
|
+
fm2 = f.call(lm2)
|
|
173
|
+
s_left = h / 12.0 * (lfa + 4.0 * fm1 + lfm)
|
|
174
|
+
s_right = h / 12.0 * (lfm + 4.0 * fm2 + lfb)
|
|
175
|
+
s_refined = s_left + s_right
|
|
176
|
+
|
|
177
|
+
if depth >= max_depth || (s_refined - s_whole).abs <= 15.0 * l_tol
|
|
178
|
+
total += s_refined + (s_refined - s_whole) / 15.0
|
|
179
|
+
else
|
|
180
|
+
stack.push([lm, lb, lfm, lfb, fm2, s_right, l_tol / 2.0, depth + 1])
|
|
181
|
+
stack.push([la, lm, lfa, lfm, fm1, s_left, l_tol / 2.0, depth + 1])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
[[0.0, total].max, 1.0].min
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/atmospheris/version.rb
CHANGED
data/lib/atmospheris.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atmospheris
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-08-29 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: bigdecimal
|
|
@@ -53,15 +54,16 @@ dependencies:
|
|
|
53
54
|
version: '0'
|
|
54
55
|
description: |-
|
|
55
56
|
Implementation of the ISO Standard Atmosphere (ISA) model as
|
|
56
|
-
defined in ISO 2533 and ICAO 7488/3 1994
|
|
57
|
-
|
|
57
|
+
defined in ISO 2533 and ICAO 7488/3 1994, plus ISO 5878
|
|
58
|
+
reference atmospheres with wind distribution (Rice) calculations.
|
|
59
|
+
Reference implementation used in ISO 2533:2025.
|
|
58
60
|
email:
|
|
59
61
|
- open.source@ribose.com
|
|
60
62
|
executables: []
|
|
61
63
|
extensions: []
|
|
62
64
|
extra_rdoc_files:
|
|
63
|
-
- LICENSE.txt
|
|
64
65
|
- README.adoc
|
|
66
|
+
- LICENSE.txt
|
|
65
67
|
files:
|
|
66
68
|
- LICENSE.txt
|
|
67
69
|
- README.adoc
|
|
@@ -85,18 +87,28 @@ files:
|
|
|
85
87
|
- lib/atmospheris/export/iso_25332025.rb
|
|
86
88
|
- lib/atmospheris/export/iso_25332025/altitude_attrs_group.rb
|
|
87
89
|
- lib/atmospheris/export/iso_25332025/combined_altitude_attrs_group.rb
|
|
90
|
+
- lib/atmospheris/export/iso_5878.rb
|
|
91
|
+
- lib/atmospheris/export/iso_5878/atmosphere_profile_export.rb
|
|
92
|
+
- lib/atmospheris/export/iso_5878/wind_table_export.rb
|
|
88
93
|
- lib/atmospheris/export/precision_value.rb
|
|
89
94
|
- lib/atmospheris/export/pressure_attrs.rb
|
|
90
95
|
- lib/atmospheris/export/utils.rb
|
|
91
96
|
- lib/atmospheris/isa.rb
|
|
97
|
+
- lib/atmospheris/iso5878.rb
|
|
98
|
+
- lib/atmospheris/iso5878/atmosphere_profile.rb
|
|
99
|
+
- lib/atmospheris/iso5878/model_registry.rb
|
|
100
|
+
- lib/atmospheris/iso5878/rice_distribution.rb
|
|
101
|
+
- lib/atmospheris/iso5878/surface_parameters.rb
|
|
102
|
+
- lib/atmospheris/iso5878/wind_observation.rb
|
|
92
103
|
- lib/atmospheris/namespace.rb
|
|
93
104
|
- lib/atmospheris/unit_value_float.rb
|
|
94
105
|
- lib/atmospheris/unit_value_integer.rb
|
|
95
106
|
- lib/atmospheris/version.rb
|
|
96
|
-
homepage: https://github.com/
|
|
107
|
+
homepage: https://github.com/atmospheris/atmospheris
|
|
97
108
|
licenses:
|
|
98
109
|
- BSD-2-Clause
|
|
99
110
|
metadata: {}
|
|
111
|
+
post_install_message:
|
|
100
112
|
rdoc_options: []
|
|
101
113
|
require_paths:
|
|
102
114
|
- lib
|
|
@@ -111,7 +123,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
111
123
|
- !ruby/object:Gem::Version
|
|
112
124
|
version: '0'
|
|
113
125
|
requirements: []
|
|
114
|
-
rubygems_version: 3.
|
|
126
|
+
rubygems_version: 3.5.22
|
|
127
|
+
signing_key:
|
|
115
128
|
specification_version: 4
|
|
116
|
-
summary:
|
|
129
|
+
summary: ISO 2533 Standard Atmosphere and ISO 5878 Reference Atmospheres
|
|
117
130
|
test_files: []
|