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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f654d212bfc8c25b833e2160aed6f2239c0aff5370a93c619a453adf069b9b6
4
- data.tar.gz: bc4f7f631574da017f4d6d52417e373738e91c6b9d583aca7db167273cb0d4a9
3
+ metadata.gz: 82f15c900161ab68525b236a39381bfaab2b04319299b2141f4f2e4c31096201
4
+ data.tar.gz: fe1733ba6452b666a0cc8d0a02706eae8ec8eb74e725ee950d7ed7ccf0c30bdf
5
5
  SHA512:
6
- metadata.gz: 80d70a6abca6270efaebd34e3b7f6b24e0820bad8a56703d50a550e38e055dafdf916eb0435a88bb48491e150ffffa5aff47ff8b1a9eccc3636c7526f90862a8
7
- data.tar.gz: 893fff10ca11df76ccb6f1bdcc4017063dec1ae9396dc8947487feced0cdc1c7c99a2cdce11fe06b2bed18099fe4ba90236f223ceaf379c9f15cd38cce7c9dc2
6
+ metadata.gz: d3dd8fc1684642111da82a16c92c761fb8ba962b7514f3ad0fbf1b1b7c3568a529a2c649647758af16e69fdb812e6ce8518c542bec89c1ec8efa284f301b7b17
7
+ data.tar.gz: 5317c6deb64a6cf1ffc7b9ff276bafd91f31df37d52d76c0827043263a730d530497ab10d44b51ddff5b72f64dec0e523ffb8a840dc0a59970679c4c67a74a26
data/README.adoc CHANGED
@@ -1,4 +1,4 @@
1
- = Atmospheris for Ruby (ISO Standard Atmosphere / ICAO Standard Atmosphere (ISA))
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
- Which are technically identical standards but different in presentation and
13
- units (the ICAO document includes `ft` in addition to `m`).
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
@@ -13,5 +13,6 @@ module Atmospheris
13
13
  autoload :Iso25331985, "atmospheris/export/iso_25331985"
14
14
  autoload :Iso25331997, "atmospheris/export/iso_25331997"
15
15
  autoload :Iso25332025, "atmospheris/export/iso_25332025"
16
+ autoload :Iso5878, "atmospheris/export/iso_5878"
16
17
  end
17
18
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Atmospheris
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/atmospheris.rb CHANGED
@@ -11,4 +11,5 @@ module Atmospheris
11
11
  autoload :UnitValueFloat, "atmospheris/unit_value_float"
12
12
  autoload :UnitValueInteger, "atmospheris/unit_value_integer"
13
13
  autoload :Export, "atmospheris/export"
14
+ autoload :Iso5878, "atmospheris/iso5878"
14
15
  end
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.5.1
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: 1980-01-02 00:00:00.000000000 Z
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
- Reference implementation used in ISO 2533:2025."
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/metanorma/atmospheris
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.6.9
126
+ rubygems_version: 3.5.22
127
+ signing_key:
115
128
  specification_version: 4
116
- summary: Implementation of the ISO Standard Atmosphere (ISA) model"
129
+ summary: ISO 2533 Standard Atmosphere and ISO 5878 Reference Atmospheres
117
130
  test_files: []